Compare commits
50 Commits
feature-yo
...
main
| 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 | |
|
|
ca7c0858e2 | |
|
|
e1386b891e | |
|
|
eff711e03e | |
|
|
f97ecb29e9 | |
|
|
08d47a6990 | |
|
|
5700965fae | |
|
|
19bd12d581 | |
|
|
18b18e9ff2 | |
|
|
f73be9c6d0 | |
|
|
f4821bf157 |
|
|
@ -1 +1 @@
|
||||||
3.14
|
3.13.11
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,9 @@ uv sync
|
||||||
|
|
||||||
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
# 이미 venv를 만든 경우 (기존 가상환경 활성화 필요)
|
||||||
uv sync --active
|
uv sync --active
|
||||||
|
|
||||||
|
playwright install
|
||||||
|
playwright install-deps
|
||||||
```
|
```
|
||||||
|
|
||||||
### 서버 실행
|
### 서버 실행
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from app.database.session import engine
|
||||||
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
|
||||||
from app.lyric.api.lyrics_admin import LyricAdmin
|
from app.lyric.api.lyrics_admin import LyricAdmin
|
||||||
from app.song.api.song_admin import SongAdmin
|
from app.song.api.song_admin import SongAdmin
|
||||||
|
from app.sns.api.sns_admin import SNSUploadTaskAdmin
|
||||||
|
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||||
from app.video.api.video_admin import VideoAdmin
|
from app.video.api.video_admin import VideoAdmin
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
|
|
@ -35,4 +37,12 @@ def init_admin(
|
||||||
# 영상 관리
|
# 영상 관리
|
||||||
admin.add_view(VideoAdmin)
|
admin.add_view(VideoAdmin)
|
||||||
|
|
||||||
|
# 사용자 관리
|
||||||
|
admin.add_view(UserAdmin)
|
||||||
|
admin.add_view(RefreshTokenAdmin)
|
||||||
|
admin.add_view(SocialAccountAdmin)
|
||||||
|
|
||||||
|
# SNS 관리
|
||||||
|
admin.add_view(SNSUploadTaskAdmin)
|
||||||
|
|
||||||
return admin
|
return admin
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10
|
||||||
## 참고
|
## 참고
|
||||||
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
|
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
|
||||||
- status가 'completed'인 영상만 반환됩니다.
|
- status가 'completed'인 영상만 반환됩니다.
|
||||||
- 재생성된 영상 포함 모든 영상이 반환됩니다.
|
- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다.
|
||||||
- created_at 기준 내림차순 정렬됩니다.
|
- created_at 기준 내림차순 정렬됩니다.
|
||||||
""",
|
""",
|
||||||
response_model=PaginatedResponse[VideoListItem],
|
response_model=PaginatedResponse[VideoListItem],
|
||||||
|
|
@ -70,112 +70,50 @@ async def get_videos(
|
||||||
) -> PaginatedResponse[VideoListItem]:
|
) -> PaginatedResponse[VideoListItem]:
|
||||||
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
||||||
logger.info(
|
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:
|
try:
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
offset = (pagination.page - 1) * pagination.page_size
|
||||||
|
|
||||||
# DEBUG: 각 조건별 데이터 수 확인
|
# 서브쿼리: task_id별 최신 Video ID 추출
|
||||||
# 1) 전체 Video 수
|
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
|
||||||
all_videos_result = await session.execute(select(func.count(Video.id)))
|
latest_video_ids = (
|
||||||
all_videos_count = all_videos_result.scalar() or 0
|
select(func.max(Video.id).label("latest_id"))
|
||||||
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))
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
.join(Project, Video.project_id == Project.id)
|
||||||
.where(
|
.where(
|
||||||
Project.user_uuid == current_user.user_uuid,
|
Project.user_uuid == current_user.user_uuid,
|
||||||
Video.status == "completed",
|
Video.status == "completed",
|
||||||
Video.is_deleted == False,
|
Video.is_deleted == False, # noqa: E712
|
||||||
Project.is_deleted == False,
|
Project.is_deleted == False, # noqa: E712
|
||||||
)
|
)
|
||||||
)
|
.group_by(Video.task_id)
|
||||||
user_completed_videos_count = user_completed_videos_result.scalar() or 0
|
.subquery()
|
||||||
logger.debug(
|
|
||||||
f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 기본 조건: 현재 사용자 소유, completed 상태, 미삭제
|
# 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만)
|
||||||
base_conditions = [
|
count_query = select(func.count(Video.id)).where(
|
||||||
Project.user_uuid == current_user.user_uuid,
|
Video.id.in_(select(latest_video_ids.c.latest_id))
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False,
|
|
||||||
Project.is_deleted == False,
|
|
||||||
]
|
|
||||||
|
|
||||||
# 쿼리 1: 전체 개수 조회 (모든 영상)
|
|
||||||
count_query = (
|
|
||||||
select(func.count(Video.id))
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(*base_conditions)
|
|
||||||
)
|
)
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}")
|
|
||||||
|
|
||||||
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상)
|
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
|
||||||
query = (
|
data_query = (
|
||||||
select(Video, Project)
|
select(Video, Project)
|
||||||
.join(Project, Video.project_id == Project.id)
|
.join(Project, Video.project_id == Project.id)
|
||||||
.where(*base_conditions)
|
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
||||||
.order_by(Video.created_at.desc())
|
.order_by(Video.created_at.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(pagination.page_size)
|
.limit(pagination.page_size)
|
||||||
)
|
)
|
||||||
result = await session.execute(query)
|
result = await session.execute(data_query)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}")
|
|
||||||
|
|
||||||
# VideoListItem으로 변환 (JOIN 결과에서 바로 추출)
|
# VideoListItem으로 변환
|
||||||
items = []
|
items = [
|
||||||
for video, project in rows:
|
VideoListItem(
|
||||||
item = VideoListItem(
|
|
||||||
video_id=video.id,
|
video_id=video.id,
|
||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region,
|
region=project.region,
|
||||||
|
|
@ -183,7 +121,8 @@ async def get_videos(
|
||||||
result_movie_url=video.result_movie_url,
|
result_movie_url=video.result_movie_url,
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
)
|
)
|
||||||
items.append(item)
|
for video, project in rows
|
||||||
|
]
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
response = PaginatedResponse.create(
|
||||||
items=items,
|
items=items,
|
||||||
|
|
|
||||||
|
|
@ -291,24 +291,30 @@ def add_exception_handlers(app: FastAPI):
|
||||||
# SocialException 핸들러 추가
|
# SocialException 핸들러 추가
|
||||||
from app.social.exceptions import SocialException
|
from app.social.exceptions import SocialException
|
||||||
|
|
||||||
|
from app.social.exceptions import TokenExpiredError
|
||||||
|
|
||||||
@app.exception_handler(SocialException)
|
@app.exception_handler(SocialException)
|
||||||
def social_exception_handler(request: Request, exc: SocialException) -> Response:
|
def social_exception_handler(request: Request, exc: SocialException) -> Response:
|
||||||
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
|
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
|
||||||
return JSONResponse(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content = {
|
content = {
|
||||||
"detail": exc.message,
|
"detail": exc.message,
|
||||||
"code": exc.code,
|
"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)
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
def internal_server_error_handler(request, exception):
|
def internal_server_error_handler(request, exception):
|
||||||
|
# 에러 메시지 로깅 (한글 포함 가능)
|
||||||
|
logger.error(f"Internal Server Error: {exception}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"detail": "Something went wrong..."},
|
content={"detail": "Something went wrong..."},
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
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
|
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(
|
_shipment_verification_codes = Redis(
|
||||||
host=db_settings.REDIS_HOST,
|
host=db_settings.REDIS_HOST,
|
||||||
port=db_settings.REDIS_PORT,
|
port=db_settings.REDIS_PORT,
|
||||||
|
|
@ -16,15 +11,10 @@ _shipment_verification_codes = Redis(
|
||||||
decode_responses=True,
|
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):
|
async def add_shipment_verification_code(id: UUID, code: int):
|
||||||
await _shipment_verification_codes.set(str(id), code)
|
await _shipment_verification_codes.set(str(id), code)
|
||||||
|
|
||||||
|
|
||||||
async def get_shipment_verification_code(id: UUID) -> str:
|
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,10 +73,12 @@ async def create_db_tables():
|
||||||
|
|
||||||
# 모델 import (테이블 메타데이터 등록용)
|
# 모델 import (테이블 메타데이터 등록용)
|
||||||
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
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.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||||
from app.video.models import Video # 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 = [
|
tables_to_create = [
|
||||||
|
|
@ -89,6 +91,9 @@ async def create_db_tables():
|
||||||
Song.__table__,
|
Song.__table__,
|
||||||
SongTimestamp.__table__,
|
SongTimestamp.__table__,
|
||||||
Video.__table__,
|
Video.__table__,
|
||||||
|
SNSUploadTask.__table__,
|
||||||
|
SocialUpload.__table__,
|
||||||
|
MarketingIntel.__table__,
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
|
|
@ -121,7 +126,9 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session, AsyncSessionLocal
|
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.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.home.schemas.home_schema import (
|
from app.home.schemas.home_schema import (
|
||||||
|
|
@ -153,8 +153,10 @@ def _extract_region_from_address(road_address: str | None) -> str:
|
||||||
},
|
},
|
||||||
tags=["Crawling"],
|
tags=["Crawling"],
|
||||||
)
|
)
|
||||||
async def crawling(request_body: CrawlingRequest):
|
async def crawling(
|
||||||
return await _crawling_logic(request_body.url)
|
request_body: CrawlingRequest,
|
||||||
|
session: AsyncSession = Depends(get_session)):
|
||||||
|
return await _crawling_logic(request_body.url, session)
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/autocomplete",
|
"/autocomplete",
|
||||||
|
|
@ -187,11 +189,15 @@ async def crawling(request_body: CrawlingRequest):
|
||||||
},
|
},
|
||||||
tags=["Crawling"],
|
tags=["Crawling"],
|
||||||
)
|
)
|
||||||
async def autocomplete_crawling(request_body: AutoCompleteRequest):
|
async def autocomplete_crawling(
|
||||||
url = await _autocomplete_logic(request_body.dict())
|
request_body: AutoCompleteRequest,
|
||||||
return await _crawling_logic(url)
|
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()
|
request_start = time.perf_counter()
|
||||||
logger.info("[crawling] ========== START ==========")
|
logger.info("[crawling] ========== START ==========")
|
||||||
logger.info(f"[crawling] URL: {url[:80]}...")
|
logger.info(f"[crawling] URL: {url[:80]}...")
|
||||||
|
|
@ -281,6 +287,15 @@ async def _crawling_logic(url:str):
|
||||||
structured_report = await chatgpt_service.generate_structured_output(
|
structured_report = await chatgpt_service.generate_structured_output(
|
||||||
marketing_prompt, input_marketing_data
|
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
|
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
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,
|
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||||
"processed_info": processed_info,
|
"processed_info": processed_info,
|
||||||
"marketing_analysis": marketing_analysis,
|
"marketing_analysis": marketing_analysis,
|
||||||
|
"m_id" : m_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -375,7 +391,7 @@ async def _autocomplete_logic(autocomplete_item:dict):
|
||||||
)
|
)
|
||||||
logger.exception("[crawling] Autocomplete 상세 오류:")
|
logger.exception("[crawling] Autocomplete 상세 오류:")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="자동완성 place id 추출 실패",
|
detail="자동완성 place id 추출 실패",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ Home 모듈 SQLAlchemy 모델 정의
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
@ -107,6 +107,12 @@ class Project(Base):
|
||||||
comment="상세 지역 정보",
|
comment="상세 지역 정보",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||||
|
)
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
@ -249,3 +255,66 @@ class Image(Base):
|
||||||
return (
|
return (
|
||||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
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 pydantic import BaseModel, ConfigDict, Field
|
||||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
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):
|
class CrawlingRequest(BaseModel):
|
||||||
"""크롤링 요청 스키마"""
|
"""크롤링 요청 스키마"""
|
||||||
|
|
||||||
|
|
@ -275,37 +169,44 @@ class CrawlingResponse(BaseModel):
|
||||||
],
|
],
|
||||||
"selling_points": [
|
"selling_points": [
|
||||||
{
|
{
|
||||||
"category": "LOCATION",
|
"english_category": "LOCATION",
|
||||||
|
"korean_category": "입지 환경",
|
||||||
"description": "군산 감성 동선",
|
"description": "군산 감성 동선",
|
||||||
"score": 88
|
"score": 88
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "HEALING",
|
"english_category": "HEALING",
|
||||||
|
"korean_category": "힐링 요소",
|
||||||
"description": "멈춤이 되는 쉼",
|
"description": "멈춤이 되는 쉼",
|
||||||
"score": 92
|
"score": 92
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "PRIVACY",
|
"english_category": "PRIVACY",
|
||||||
|
"korean_category": "프라이버시",
|
||||||
"description": "방해 없는 머뭄",
|
"description": "방해 없는 머뭄",
|
||||||
"score": 86
|
"score": 86
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "NIGHT MOOD",
|
"english_category": "NIGHT MOOD",
|
||||||
|
"korean_category": "야간 감성",
|
||||||
"description": "밤이 예쁜 조명",
|
"description": "밤이 예쁜 조명",
|
||||||
"score": 84
|
"score": 84
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "PHOTO SPOT",
|
"english_category": "PHOTO SPOT",
|
||||||
|
"korean_category": "포토 스팟",
|
||||||
"description": "자연광 포토존",
|
"description": "자연광 포토존",
|
||||||
"score": 83
|
"score": 83
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "SHORT GETAWAY",
|
"english_category": "SHORT GETAWAY",
|
||||||
|
"korean_category": "숏브레이크",
|
||||||
"description": "주말 리셋 스테이",
|
"description": "주말 리셋 스테이",
|
||||||
"score": 89
|
"score": 89
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "HOSPITALITY",
|
"english_category": "HOSPITALITY",
|
||||||
|
"korean_category": "서비스",
|
||||||
"description": "세심한 웰컴감",
|
"description": "세심한 웰컴감",
|
||||||
"score": 80
|
"score": 80
|
||||||
}
|
}
|
||||||
|
|
@ -322,7 +223,8 @@ class CrawlingResponse(BaseModel):
|
||||||
"힐링스테이",
|
"힐링스테이",
|
||||||
"스테이머뭄"
|
"스테이머뭄"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"m_id" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -339,6 +241,7 @@ class CrawlingResponse(BaseModel):
|
||||||
marketing_analysis: Optional[MarketingPromptOutput] = Field(
|
marketing_analysis: Optional[MarketingPromptOutput] = Field(
|
||||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||||
)
|
)
|
||||||
|
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
|
|
@ -362,29 +265,6 @@ class ImageUrlItem(BaseModel):
|
||||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
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):
|
class ImageUploadResultItem(BaseModel):
|
||||||
"""업로드된 이미지 결과 아이템"""
|
"""업로드된 이미지 결과 아이템"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
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.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.lyric.models import Lyric
|
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
|
from app.utils.prompts.prompts import lyric_prompt
|
||||||
import traceback as tb
|
import traceback as tb
|
||||||
|
import json
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("lyric")
|
logger = get_logger("lyric")
|
||||||
|
|
||||||
|
|
@ -278,16 +279,19 @@ async def generate_lyric(
|
||||||
Full verse flow, immersive mood
|
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 = {
|
lyric_input_data = {
|
||||||
"customer_name" : request_body.customer_name,
|
"customer_name" : request_body.customer_name,
|
||||||
"region" : request_body.region,
|
"region" : request_body.region,
|
||||||
"detail_region_info" : request_body.detail_region_info or "",
|
"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,
|
"language" : request_body.language,
|
||||||
"promotional_expression_example" : promotional_expressions[request_body.language],
|
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||||
}
|
}
|
||||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||||
|
|
@ -314,6 +318,7 @@ async def generate_lyric(
|
||||||
detail_region_info=request_body.detail_region_info,
|
detail_region_info=request_body.detail_region_info,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
|
marketing_intelligence = request_body.m_id
|
||||||
)
|
)
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ class GenerateLyricRequest(BaseModel):
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean"
|
"language": "Korean",
|
||||||
|
"m_id" : 1
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ class GenerateLyricRequest(BaseModel):
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
|
"m_id" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -67,6 +69,7 @@ class GenerateLyricRequest(BaseModel):
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricResponse(BaseModel):
|
class GenerateLyricResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""
|
||||||
|
SNS API 라우터
|
||||||
|
|
||||||
|
Instagram 업로드 관련 엔드포인트를 제공합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
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__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SNS 예외 클래스 정의
|
||||||
|
# =============================================================================
|
||||||
|
class SNSException(HTTPException):
|
||||||
|
"""SNS 관련 기본 예외"""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, code: str, message: str):
|
||||||
|
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountNotFoundError(SNSException):
|
||||||
|
"""소셜 계정 없음"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "연동된 소셜 계정을 찾을 수 없습니다."):
|
||||||
|
super().__init__(status.HTTP_404_NOT_FOUND, "SOCIAL_ACCOUNT_NOT_FOUND", message)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoNotFoundError(SNSException):
|
||||||
|
"""비디오 없음"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "해당 작업 ID에 대한 비디오를 찾을 수 없습니다."):
|
||||||
|
super().__init__(status.HTTP_404_NOT_FOUND, "VIDEO_NOT_FOUND", message)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoUrlNotReadyError(SNSException):
|
||||||
|
"""비디오 URL 미준비"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "비디오가 아직 준비되지 않았습니다."):
|
||||||
|
super().__init__(status.HTTP_400_BAD_REQUEST, "VIDEO_URL_NOT_READY", message)
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramUploadError(SNSException):
|
||||||
|
"""Instagram 업로드 실패"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Instagram 업로드에 실패했습니다."):
|
||||||
|
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_UPLOAD_ERROR", message)
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramRateLimitError(SNSException):
|
||||||
|
"""Instagram API Rate Limit"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Instagram API 호출 제한을 초과했습니다.", retry_after: int = 60):
|
||||||
|
super().__init__(
|
||||||
|
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
"INSTAGRAM_RATE_LIMIT",
|
||||||
|
f"{message} {retry_after}초 후 다시 시도해주세요.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramAuthError(SNSException):
|
||||||
|
"""Instagram 인증 오류"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Instagram 인증에 실패했습니다. 계정을 다시 연동해주세요."):
|
||||||
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "INSTAGRAM_AUTH_ERROR", message)
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramContainerTimeoutError(SNSException):
|
||||||
|
"""Instagram 미디어 처리 타임아웃"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Instagram 미디어 처리 시간이 초과되었습니다."):
|
||||||
|
super().__init__(status.HTTP_504_GATEWAY_TIMEOUT, "INSTAGRAM_CONTAINER_TIMEOUT", message)
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramContainerError(SNSException):
|
||||||
|
"""Instagram 미디어 컨테이너 오류"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
|
||||||
|
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sns", tags=["SNS"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/instagram/upload/{task_id}",
|
||||||
|
summary="Instagram 비디오 업로드",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
task_id에 해당하는 비디오를 Instagram에 업로드합니다.
|
||||||
|
|
||||||
|
## 경로 파라미터
|
||||||
|
- **task_id**: 비디오 생성 작업 고유 식별자
|
||||||
|
|
||||||
|
## 요청 본문
|
||||||
|
- **caption**: 게시물 캡션 (선택, 최대 2200자)
|
||||||
|
- **share_to_feed**: 피드에 공유 여부 (기본값: true)
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필요 (Authorization: Bearer <token>)
|
||||||
|
- 사용자의 Instagram 계정이 연동되어 있어야 합니다.
|
||||||
|
|
||||||
|
## 반환 정보
|
||||||
|
- **task_id**: 작업 고유 식별자
|
||||||
|
- **state**: 업로드 상태 (completed, failed)
|
||||||
|
- **message**: 상태 메시지
|
||||||
|
- **media_id**: Instagram 미디어 ID (성공 시)
|
||||||
|
- **permalink**: Instagram 게시물 URL (성공 시)
|
||||||
|
- **error**: 에러 메시지 (실패 시)
|
||||||
|
""",
|
||||||
|
response_model=InstagramUploadResponse,
|
||||||
|
responses={
|
||||||
|
200: {"description": "업로드 성공"},
|
||||||
|
400: {"description": "비디오 URL 미준비"},
|
||||||
|
401: {"description": "인증 실패"},
|
||||||
|
404: {"description": "비디오 또는 소셜 계정 없음"},
|
||||||
|
429: {"description": "Instagram API Rate Limit"},
|
||||||
|
500: {"description": "업로드 실패"},
|
||||||
|
504: {"description": "타임아웃"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def upload_to_instagram(
|
||||||
|
task_id: str,
|
||||||
|
request: InstagramUploadRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> InstagramUploadResponse:
|
||||||
|
"""Instagram에 비디오를 업로드합니다."""
|
||||||
|
logger.info(f"[upload_to_instagram] START - task_id: {task_id}, user_uuid: {current_user.user_uuid}")
|
||||||
|
|
||||||
|
# Step 1: 사용자의 Instagram 소셜 계정 조회
|
||||||
|
social_account_result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == current_user.user_uuid,
|
||||||
|
SocialAccount.platform == Platform.INSTAGRAM,
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
SocialAccount.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
social_account = social_account_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if social_account is None:
|
||||||
|
logger.warning(f"[upload_to_instagram] Instagram 계정 없음 - user_uuid: {current_user.user_uuid}")
|
||||||
|
raise SocialAccountNotFoundError("연동된 Instagram 계정을 찾을 수 없습니다.")
|
||||||
|
|
||||||
|
logger.info(f"[upload_to_instagram] 소셜 계정 확인 - social_account_id: {social_account.id}")
|
||||||
|
|
||||||
|
# Step 2: task_id로 비디오 조회 (가장 최근 것)
|
||||||
|
video_result = await session.execute(
|
||||||
|
select(Video)
|
||||||
|
.where(
|
||||||
|
Video.task_id == task_id,
|
||||||
|
Video.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(Video.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
video = video_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if video is None:
|
||||||
|
logger.warning(f"[upload_to_instagram] 비디오 없음 - task_id: {task_id}")
|
||||||
|
raise VideoNotFoundError(f"task_id '{task_id}'에 해당하는 비디오를 찾을 수 없습니다.")
|
||||||
|
|
||||||
|
if video.result_movie_url is None:
|
||||||
|
logger.warning(f"[upload_to_instagram] 비디오 URL 미준비 - task_id: {task_id}, status: {video.status}")
|
||||||
|
raise VideoUrlNotReadyError("비디오가 아직 처리 중입니다. 잠시 후 다시 시도해주세요.")
|
||||||
|
|
||||||
|
logger.info(f"[upload_to_instagram] 비디오 확인 - video_id: {video.id}, url: {video.result_movie_url[:50]}...")
|
||||||
|
|
||||||
|
# Step 3: Instagram 업로드
|
||||||
|
try:
|
||||||
|
async with InstagramClient(access_token=social_account.access_token) as client:
|
||||||
|
# 접속 테스트 (계정 ID 조회)
|
||||||
|
await client.get_account_id()
|
||||||
|
logger.info("[upload_to_instagram] Instagram 접속 확인 완료")
|
||||||
|
|
||||||
|
# 비디오 업로드
|
||||||
|
media = await client.publish_video(
|
||||||
|
video_url=video.result_movie_url,
|
||||||
|
caption=request.caption,
|
||||||
|
share_to_feed=request.share_to_feed,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[upload_to_instagram] SUCCESS - task_id: {task_id}, "
|
||||||
|
f"media_id: {media.id}, permalink: {media.permalink}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return InstagramUploadResponse(
|
||||||
|
task_id=task_id,
|
||||||
|
state="completed",
|
||||||
|
message="Instagram 업로드 완료",
|
||||||
|
media_id=media.id,
|
||||||
|
permalink=media.permalink,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_state, message, extra_info = parse_instagram_error(e)
|
||||||
|
logger.error(f"[upload_to_instagram] FAILED - task_id: {task_id}, error_state: {error_state}, message: {message}")
|
||||||
|
|
||||||
|
match error_state:
|
||||||
|
case ErrorState.RATE_LIMIT:
|
||||||
|
retry_after = extra_info.get("retry_after", 60)
|
||||||
|
raise InstagramRateLimitError(retry_after=retry_after)
|
||||||
|
|
||||||
|
case ErrorState.AUTH_ERROR:
|
||||||
|
raise InstagramAuthError()
|
||||||
|
|
||||||
|
case ErrorState.CONTAINER_TIMEOUT:
|
||||||
|
raise InstagramContainerTimeoutError()
|
||||||
|
|
||||||
|
case ErrorState.CONTAINER_ERROR:
|
||||||
|
status = extra_info.get("status", "UNKNOWN")
|
||||||
|
raise InstagramContainerError(f"미디어 처리 실패: {status}")
|
||||||
|
|
||||||
|
case _:
|
||||||
|
raise InstagramUploadError(f"Instagram 업로드 실패: {message}")
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
from sqladmin import ModelView
|
||||||
|
|
||||||
|
from app.sns.models import SNSUploadTask
|
||||||
|
|
||||||
|
|
||||||
|
class SNSUploadTaskAdmin(ModelView, model=SNSUploadTask):
|
||||||
|
name = "SNS 업로드 작업"
|
||||||
|
name_plural = "SNS 업로드 작업 목록"
|
||||||
|
icon = "fa-solid fa-share-from-square"
|
||||||
|
category = "SNS 관리"
|
||||||
|
page_size = 20
|
||||||
|
|
||||||
|
column_list = [
|
||||||
|
"id",
|
||||||
|
"user_uuid",
|
||||||
|
"task_id",
|
||||||
|
"social_account_id",
|
||||||
|
"is_scheduled",
|
||||||
|
"status",
|
||||||
|
"scheduled_at",
|
||||||
|
"uploaded_at",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
column_details_list = [
|
||||||
|
"id",
|
||||||
|
"user_uuid",
|
||||||
|
"task_id",
|
||||||
|
"social_account_id",
|
||||||
|
"is_scheduled",
|
||||||
|
"scheduled_at",
|
||||||
|
"url",
|
||||||
|
"caption",
|
||||||
|
"status",
|
||||||
|
"uploaded_at",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
form_excluded_columns = ["created_at", "user", "social_account"]
|
||||||
|
|
||||||
|
column_searchable_list = [
|
||||||
|
SNSUploadTask.user_uuid,
|
||||||
|
SNSUploadTask.task_id,
|
||||||
|
SNSUploadTask.status,
|
||||||
|
]
|
||||||
|
|
||||||
|
column_default_sort = (SNSUploadTask.created_at, True)
|
||||||
|
|
||||||
|
column_sortable_list = [
|
||||||
|
SNSUploadTask.id,
|
||||||
|
SNSUploadTask.user_uuid,
|
||||||
|
SNSUploadTask.social_account_id,
|
||||||
|
SNSUploadTask.is_scheduled,
|
||||||
|
SNSUploadTask.status,
|
||||||
|
SNSUploadTask.scheduled_at,
|
||||||
|
SNSUploadTask.uploaded_at,
|
||||||
|
SNSUploadTask.created_at,
|
||||||
|
]
|
||||||
|
|
||||||
|
column_labels = {
|
||||||
|
"id": "ID",
|
||||||
|
"user_uuid": "사용자 UUID",
|
||||||
|
"task_id": "작업 ID",
|
||||||
|
"social_account_id": "소셜 계정 ID",
|
||||||
|
"is_scheduled": "예약 여부",
|
||||||
|
"scheduled_at": "예약 일시",
|
||||||
|
"url": "미디어 URL",
|
||||||
|
"caption": "캡션",
|
||||||
|
"status": "상태",
|
||||||
|
"uploaded_at": "업로드 일시",
|
||||||
|
"created_at": "생성일시",
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""
|
||||||
|
SNS 모듈 SQLAlchemy 모델 정의
|
||||||
|
|
||||||
|
SNS 업로드 작업 관리 모델입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database.session import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.user.models import SocialAccount, User
|
||||||
|
|
||||||
|
|
||||||
|
class SNSUploadTask(Base):
|
||||||
|
"""
|
||||||
|
SNS 업로드 작업 테이블
|
||||||
|
|
||||||
|
SNS 플랫폼에 콘텐츠를 업로드하는 작업을 관리합니다.
|
||||||
|
즉시 업로드 또는 예약 업로드를 지원합니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: 고유 식별자 (자동 증가)
|
||||||
|
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||||
|
task_id: 외부 작업 식별자 (비디오 생성 작업 등)
|
||||||
|
is_scheduled: 예약 작업 여부 (True: 예약, False: 즉시)
|
||||||
|
scheduled_at: 예약 발행 일시 (분 단위까지)
|
||||||
|
social_account_id: 소셜 계정 외래키 (SocialAccount.id 참조)
|
||||||
|
url: 업로드할 미디어 URL
|
||||||
|
caption: 게시물 캡션/설명
|
||||||
|
status: 발행 상태 (pending: 예약 대기, completed: 완료, error: 에러)
|
||||||
|
uploaded_at: 실제 업로드 완료 일시
|
||||||
|
created_at: 작업 생성 일시
|
||||||
|
|
||||||
|
발행 상태 (status):
|
||||||
|
- pending: 예약 대기 중 (예약 작업이거나 처리 전)
|
||||||
|
- processing: 처리 중
|
||||||
|
- completed: 발행 완료
|
||||||
|
- error: 에러 발생
|
||||||
|
|
||||||
|
Relationships:
|
||||||
|
user: 작업 소유 사용자 (User 테이블 참조)
|
||||||
|
social_account: 발행 대상 소셜 계정 (SocialAccount 테이블 참조)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "sns_upload_task"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_sns_upload_task_user_uuid", "user_uuid"),
|
||||||
|
Index("idx_sns_upload_task_task_id", "task_id"),
|
||||||
|
Index("idx_sns_upload_task_social_account_id", "social_account_id"),
|
||||||
|
Index("idx_sns_upload_task_status", "status"),
|
||||||
|
Index("idx_sns_upload_task_is_scheduled", "is_scheduled"),
|
||||||
|
Index("idx_sns_upload_task_scheduled_at", "scheduled_at"),
|
||||||
|
Index("idx_sns_upload_task_created_at", "created_at"),
|
||||||
|
{
|
||||||
|
"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="고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 사용자 및 작업 식별
|
||||||
|
# ==========================================================================
|
||||||
|
user_uuid: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="사용자 UUID (User.user_uuid 참조)",
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
comment="외부 작업 식별자 (비디오 생성 작업 ID 등)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 예약 설정
|
||||||
|
# ==========================================================================
|
||||||
|
is_scheduled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
comment="예약 작업 여부 (True: 예약 발행, False: 즉시 발행)",
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=True,
|
||||||
|
comment="예약 발행 일시 (분 단위까지 지정)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 소셜 계정 연결
|
||||||
|
# ==========================================================================
|
||||||
|
social_account_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("social_account.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="소셜 계정 외래키 (SocialAccount.id 참조)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 업로드 콘텐츠
|
||||||
|
# ==========================================================================
|
||||||
|
url: Mapped[str] = mapped_column(
|
||||||
|
String(2048),
|
||||||
|
nullable=False,
|
||||||
|
comment="업로드할 미디어 URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
caption: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="게시물 캡션/설명",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 발행 상태
|
||||||
|
# ==========================================================================
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="pending",
|
||||||
|
comment="발행 상태 (pending: 예약 대기, processing: 처리 중, completed: 완료, error: 에러)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 시간 정보
|
||||||
|
# ==========================================================================
|
||||||
|
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="작업 생성 일시",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Relationships
|
||||||
|
# ==========================================================================
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
foreign_keys=[user_uuid],
|
||||||
|
primaryjoin="SNSUploadTask.user_uuid == User.user_uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
social_account: Mapped["SocialAccount"] = relationship(
|
||||||
|
"SocialAccount",
|
||||||
|
foreign_keys=[social_account_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<SNSUploadTask("
|
||||||
|
f"id={self.id}, "
|
||||||
|
f"user_uuid='{self.user_uuid}', "
|
||||||
|
f"social_account_id={self.social_account_id}, "
|
||||||
|
f"status='{self.status}', "
|
||||||
|
f"is_scheduled={self.is_scheduled}"
|
||||||
|
f")>"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""
|
||||||
|
SNS API Schemas
|
||||||
|
|
||||||
|
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramUploadRequest(BaseModel):
|
||||||
|
"""Instagram 업로드 요청 스키마
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
POST /sns/instagram/upload/{task_id}
|
||||||
|
Instagram에 비디오를 업로드합니다.
|
||||||
|
|
||||||
|
Example Request:
|
||||||
|
{
|
||||||
|
"caption": "Test video from Instagram POC #test",
|
||||||
|
"share_to_feed": true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"caption": "Test video from Instagram POC #test",
|
||||||
|
"share_to_feed": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
caption: str = Field(
|
||||||
|
default="",
|
||||||
|
description="게시물 캡션",
|
||||||
|
max_length=2200,
|
||||||
|
)
|
||||||
|
share_to_feed: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="피드에 공유 여부",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramUploadResponse(BaseModel):
|
||||||
|
"""Instagram 업로드 응답 스키마
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
POST /sns/instagram/upload/{task_id}
|
||||||
|
Instagram 업로드 작업의 결과를 반환합니다.
|
||||||
|
|
||||||
|
Example Response (성공):
|
||||||
|
{
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
|
"state": "completed",
|
||||||
|
"message": "Instagram 업로드 완료",
|
||||||
|
"media_id": "17841405822304914",
|
||||||
|
"permalink": "https://www.instagram.com/p/ABC123/",
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||||
|
"state": "completed",
|
||||||
|
"message": "Instagram 업로드 완료",
|
||||||
|
"media_id": "17841405822304914",
|
||||||
|
"permalink": "https://www.instagram.com/p/ABC123/",
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
state: str = Field(..., description="업로드 상태 (pending, processing, completed, failed)")
|
||||||
|
message: str = Field(..., description="상태 메시지")
|
||||||
|
media_id: Optional[str] = Field(default=None, description="Instagram 미디어 ID (성공 시)")
|
||||||
|
permalink: Optional[str] = Field(default=None, description="Instagram 게시물 URL (성공 시)")
|
||||||
|
error: Optional[str] = Field(default=None, description="에러 메시지 (실패 시)")
|
||||||
|
|
||||||
|
|
||||||
|
class Media(BaseModel):
|
||||||
|
"""Instagram 미디어 정보"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
media_type: Optional[str] = None
|
||||||
|
media_url: Optional[str] = None
|
||||||
|
thumbnail_url: Optional[str] = None
|
||||||
|
caption: Optional[str] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
permalink: Optional[str] = None
|
||||||
|
like_count: int = 0
|
||||||
|
comments_count: int = 0
|
||||||
|
children: Optional[list["Media"]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MediaContainer(BaseModel):
|
||||||
|
"""미디어 컨테이너 상태"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
status_code: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_finished(self) -> bool:
|
||||||
|
return self.status_code == "FINISHED"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_error(self) -> bool:
|
||||||
|
return self.status_code == "ERROR"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_in_progress(self) -> bool:
|
||||||
|
return self.status_code == "IN_PROGRESS"
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(BaseModel):
|
||||||
|
"""API 에러 응답"""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
type: Optional[str] = None
|
||||||
|
code: Optional[int] = None
|
||||||
|
error_subcode: Optional[int] = None
|
||||||
|
fbtrace_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""에러 응답 래퍼"""
|
||||||
|
|
||||||
|
error: APIError
|
||||||
|
|
@ -4,5 +4,5 @@ Social API Routers v1
|
||||||
|
|
||||||
from app.social.api.routers.v1.oauth import router as oauth_router
|
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.upload import router as upload_router
|
||||||
|
from app.social.api.routers.v1.seo import router as seo_router
|
||||||
__all__ = ["oauth_router", "upload_router"]
|
__all__ = ["oauth_router", "upload_router", "seo_router"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging, json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -112,36 +113,45 @@ async def upload_to_social(
|
||||||
)
|
)
|
||||||
raise SocialAccountNotFoundError()
|
raise SocialAccountNotFoundError()
|
||||||
|
|
||||||
# 3. 기존 업로드 확인 (동일 video + account 조합)
|
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
||||||
existing_result = await session.execute(
|
in_progress_result = await session.execute(
|
||||||
select(SocialUpload).where(
|
select(SocialUpload).where(
|
||||||
SocialUpload.video_id == body.video_id,
|
SocialUpload.video_id == body.video_id,
|
||||||
SocialUpload.social_account_id == account.id,
|
SocialUpload.social_account_id == account.id,
|
||||||
SocialUpload.status.in_(
|
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||||
[UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_upload = existing_result.scalar_one_or_none()
|
in_progress_upload = in_progress_result.scalar_one_or_none()
|
||||||
|
|
||||||
if existing_upload:
|
if in_progress_upload:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {existing_upload.id}"
|
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
||||||
)
|
)
|
||||||
return SocialUploadResponse(
|
return SocialUploadResponse(
|
||||||
success=True,
|
success=True,
|
||||||
upload_id=existing_upload.id,
|
upload_id=in_progress_upload.id,
|
||||||
platform=account.platform,
|
platform=account.platform,
|
||||||
status=existing_upload.status,
|
status=in_progress_upload.status,
|
||||||
message="이미 업로드가 진행 중입니다.",
|
message="이미 업로드가 진행 중입니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. 새 업로드 레코드 생성
|
# 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(
|
social_upload = SocialUpload(
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
video_id=body.video_id,
|
video_id=body.video_id,
|
||||||
social_account_id=account.id,
|
social_account_id=account.id,
|
||||||
platform=account.platform, # 계정의 플랫폼 정보 사용
|
upload_seq=next_seq,
|
||||||
|
platform=account.platform,
|
||||||
status=UploadStatus.PENDING.value,
|
status=UploadStatus.PENDING.value,
|
||||||
upload_progress=0,
|
upload_progress=0,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
|
|
@ -161,10 +171,11 @@ async def upload_to_social(
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
||||||
f"upload_id: {social_upload.id}, video_id: {body.video_id}, platform: {account.platform}"
|
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
||||||
|
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. 백그라운드 태스크 등록
|
# 6. 백그라운드 태스크 등록
|
||||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||||
|
|
||||||
return SocialUploadResponse(
|
return SocialUploadResponse(
|
||||||
|
|
@ -211,6 +222,8 @@ async def get_upload_status(
|
||||||
return SocialUploadStatusResponse(
|
return SocialUploadStatusResponse(
|
||||||
upload_id=upload.id,
|
upload_id=upload.id,
|
||||||
video_id=upload.video_id,
|
video_id=upload.video_id,
|
||||||
|
social_account_id=upload.social_account_id,
|
||||||
|
upload_seq=upload.upload_seq,
|
||||||
platform=upload.platform,
|
platform=upload.platform,
|
||||||
status=UploadStatus(upload.status),
|
status=UploadStatus(upload.status),
|
||||||
upload_progress=upload.upload_progress,
|
upload_progress=upload.upload_progress,
|
||||||
|
|
@ -282,6 +295,8 @@ async def get_upload_history(
|
||||||
SocialUploadHistoryItem(
|
SocialUploadHistoryItem(
|
||||||
upload_id=upload.id,
|
upload_id=upload.id,
|
||||||
video_id=upload.video_id,
|
video_id=upload.video_id,
|
||||||
|
social_account_id=upload.social_account_id,
|
||||||
|
upload_seq=upload.upload_seq,
|
||||||
platform=upload.platform,
|
platform=upload.platform,
|
||||||
status=upload.status,
|
status=upload.status,
|
||||||
title=upload.title,
|
title=upload.title,
|
||||||
|
|
@ -389,16 +404,12 @@ async def cancel_upload(
|
||||||
upload = result.scalar_one_or_none()
|
upload = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not upload:
|
if not upload:
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
detail="업로드 정보를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if upload.status != UploadStatus.PENDING.value:
|
if upload.status != UploadStatus.PENDING.value:
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ YOUTUBE_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||||
]
|
]
|
||||||
|
|
||||||
|
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
# Instagram/Facebook OAuth Scopes (추후 구현)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ class TokenExpiredError(OAuthException):
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
code="TOKEN_EXPIRED",
|
code="TOKEN_EXPIRED",
|
||||||
)
|
)
|
||||||
|
self.platform = platform
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class SocialUpload(Base):
|
||||||
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||||
video_id: Video 외래키
|
video_id: Video 외래키
|
||||||
social_account_id: SocialAccount 외래키
|
social_account_id: SocialAccount 외래키
|
||||||
|
upload_seq: 업로드 순번 (동일 영상+채널 조합 내 순번, 관리자 추적용)
|
||||||
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
||||||
status: 업로드 상태 (pending, uploading, processing, completed, failed)
|
status: 업로드 상태 (pending, uploading, processing, completed, failed)
|
||||||
upload_progress: 업로드 진행률 (0-100)
|
upload_progress: 업로드 진행률 (0-100)
|
||||||
|
|
@ -58,12 +59,10 @@ class SocialUpload(Base):
|
||||||
Index("idx_social_upload_platform", "platform"),
|
Index("idx_social_upload_platform", "platform"),
|
||||||
Index("idx_social_upload_status", "status"),
|
Index("idx_social_upload_status", "status"),
|
||||||
Index("idx_social_upload_created_at", "created_at"),
|
Index("idx_social_upload_created_at", "created_at"),
|
||||||
Index(
|
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능)
|
||||||
"uq_social_upload_video_platform",
|
Index("idx_social_upload_video_account", "video_id", "social_account_id"),
|
||||||
"video_id",
|
# 순번 조회용 인덱스
|
||||||
"social_account_id",
|
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"),
|
||||||
unique=True,
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
|
|
@ -106,6 +105,16 @@ class SocialUpload(Base):
|
||||||
comment="SocialAccount 외래키",
|
comment="SocialAccount 외래키",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 업로드 순번 (관리자 추적용)
|
||||||
|
# ==========================================================================
|
||||||
|
upload_seq: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=1,
|
||||||
|
comment="업로드 순번 (동일 영상+채널 조합 내 순번, 1부터 시작)",
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 플랫폼 정보
|
# 플랫폼 정보
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|
@ -238,8 +247,10 @@ class SocialUpload(Base):
|
||||||
return (
|
return (
|
||||||
f"<SocialUpload("
|
f"<SocialUpload("
|
||||||
f"id={self.id}, "
|
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"platform='{self.platform}', "
|
||||||
f"status='{self.status}', "
|
f"status='{self.status}'"
|
||||||
f"video_id={self.video_id}"
|
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": " ".join(YOUTUBE_SCOPES),
|
"scope": " ".join(YOUTUBE_SCOPES),
|
||||||
"access_type": "offline", # refresh_token 받기 위해 필요
|
"access_type": "offline", # refresh_token 받기 위해 필요
|
||||||
"prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지)
|
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
|
||||||
"state": state,
|
"state": state,
|
||||||
}
|
}
|
||||||
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,8 @@ class SocialUploadStatusResponse(BaseModel):
|
||||||
|
|
||||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||||
video_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="플랫폼명")
|
platform: str = Field(..., description="플랫폼명")
|
||||||
status: UploadStatus = Field(..., description="업로드 상태")
|
status: UploadStatus = Field(..., description="업로드 상태")
|
||||||
upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
|
upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
|
||||||
|
|
@ -210,6 +212,8 @@ class SocialUploadStatusResponse(BaseModel):
|
||||||
"example": {
|
"example": {
|
||||||
"upload_id": 456,
|
"upload_id": 456,
|
||||||
"video_id": 123,
|
"video_id": 123,
|
||||||
|
"social_account_id": 1,
|
||||||
|
"upload_seq": 2,
|
||||||
"platform": "youtube",
|
"platform": "youtube",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"upload_progress": 100,
|
"upload_progress": 100,
|
||||||
|
|
@ -230,6 +234,8 @@ class SocialUploadHistoryItem(BaseModel):
|
||||||
|
|
||||||
upload_id: int = Field(..., description="업로드 작업 ID")
|
upload_id: int = Field(..., description="업로드 작업 ID")
|
||||||
video_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="플랫폼명")
|
platform: str = Field(..., description="플랫폼명")
|
||||||
status: str = Field(..., description="업로드 상태")
|
status: str = Field(..., description="업로드 상태")
|
||||||
title: str = Field(..., description="영상 제목")
|
title: str = Field(..., description="영상 제목")
|
||||||
|
|
@ -270,6 +276,32 @@ class SocialUploadHistoryResponse(BaseModel):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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": ["여기에", "더미", "해시태그"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 공통 응답 스키마
|
# 공통 응답 스키마
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ Social Account Service
|
||||||
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.utils.timezone import now
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
@ -25,10 +28,10 @@ redis_client = Redis(
|
||||||
decode_responses=True,
|
decode_responses=True,
|
||||||
)
|
)
|
||||||
from app.social.exceptions import (
|
from app.social.exceptions import (
|
||||||
InvalidStateError,
|
|
||||||
OAuthStateExpiredError,
|
OAuthStateExpiredError,
|
||||||
SocialAccountAlreadyConnectedError,
|
OAuthTokenRefreshError,
|
||||||
SocialAccountNotFoundError,
|
SocialAccountNotFoundError,
|
||||||
|
TokenExpiredError,
|
||||||
)
|
)
|
||||||
from app.social.oauth import get_oauth_client
|
from app.social.oauth import get_oauth_client
|
||||||
from app.social.schemas import (
|
from app.social.schemas import (
|
||||||
|
|
@ -86,7 +89,7 @@ class SocialAccountService:
|
||||||
await redis_client.setex(
|
await redis_client.setex(
|
||||||
state_key,
|
state_key,
|
||||||
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
||||||
str(state_data),
|
json.dumps(state_data), # JSON으로 직렬화
|
||||||
)
|
)
|
||||||
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
|
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
|
||||||
|
|
||||||
|
|
@ -122,9 +125,7 @@ class SocialAccountService:
|
||||||
SocialAccountResponse: 연동된 소셜 계정 정보
|
SocialAccountResponse: 연동된 소셜 계정 정보
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InvalidStateError: state 토큰이 유효하지 않은 경우
|
OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우
|
||||||
OAuthStateExpiredError: state 토큰이 만료된 경우
|
|
||||||
SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우
|
|
||||||
"""
|
"""
|
||||||
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
|
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
|
||||||
|
|
||||||
|
|
@ -136,8 +137,8 @@ class SocialAccountService:
|
||||||
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
||||||
raise OAuthStateExpiredError()
|
raise OAuthStateExpiredError()
|
||||||
|
|
||||||
# state 데이터 파싱
|
# state 데이터 파싱 (JSON 역직렬화)
|
||||||
state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."}
|
state_data = json.loads(state_data_str)
|
||||||
user_uuid = state_data["user_uuid"]
|
user_uuid = state_data["user_uuid"]
|
||||||
platform = SocialPlatform(state_data["platform"])
|
platform = SocialPlatform(state_data["platform"])
|
||||||
|
|
||||||
|
|
@ -209,13 +210,15 @@ class SocialAccountService:
|
||||||
self,
|
self,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
auto_refresh: bool = True,
|
||||||
) -> list[SocialAccountResponse]:
|
) -> list[SocialAccountResponse]:
|
||||||
"""
|
"""
|
||||||
연동된 소셜 계정 목록 조회
|
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_uuid: 사용자 UUID
|
user_uuid: 사용자 UUID
|
||||||
session: DB 세션
|
session: DB 세션
|
||||||
|
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[SocialAccountResponse]: 연동된 계정 목록
|
list[SocialAccountResponse]: 연동된 계정 목록
|
||||||
|
|
@ -233,8 +236,97 @@ class SocialAccountService:
|
||||||
|
|
||||||
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
|
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]
|
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(
|
async def get_account_by_platform(
|
||||||
self,
|
self,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
|
|
@ -402,18 +494,38 @@ class SocialAccountService:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 유효한 access_token
|
str: 유효한 access_token
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요)
|
||||||
"""
|
"""
|
||||||
# 만료 시간 확인 (만료 10분 전이면 갱신)
|
# 만료 시간 확인
|
||||||
if account.token_expires_at:
|
is_expired = False
|
||||||
buffer_time = datetime.now() + timedelta(minutes=10)
|
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:
|
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(
|
logger.info(
|
||||||
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
||||||
)
|
)
|
||||||
return await self._refresh_account_token(account, session)
|
return await self._refresh_account_token(account, session)
|
||||||
|
|
||||||
return account.access_token
|
|
||||||
|
|
||||||
async def _refresh_account_token(
|
async def _refresh_account_token(
|
||||||
self,
|
self,
|
||||||
account: SocialAccount,
|
account: SocialAccount,
|
||||||
|
|
@ -428,28 +540,46 @@ class SocialAccountService:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 새 access_token
|
str: 새 access_token
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TokenExpiredError: 갱신 실패 시 (재연동 필요)
|
||||||
"""
|
"""
|
||||||
if not account.refresh_token:
|
if not account.refresh_token:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}"
|
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
|
||||||
)
|
)
|
||||||
return account.access_token
|
raise TokenExpiredError(platform=account.platform)
|
||||||
|
|
||||||
platform = SocialPlatform(account.platform)
|
platform = SocialPlatform(account.platform)
|
||||||
oauth_client = get_oauth_client(platform)
|
oauth_client = get_oauth_client(platform)
|
||||||
|
|
||||||
|
try:
|
||||||
token_response = await oauth_client.refresh_token(account.refresh_token)
|
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
|
account.access_token = token_response.access_token
|
||||||
if token_response.refresh_token:
|
if token_response.refresh_token:
|
||||||
account.refresh_token = token_response.refresh_token
|
account.refresh_token = token_response.refresh_token
|
||||||
if token_response.expires_in:
|
if token_response.expires_in:
|
||||||
account.token_expires_at = datetime.now() + timedelta(
|
# DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원)
|
||||||
|
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||||
seconds=token_response.expires_in
|
seconds=token_response.expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await session.refresh(account)
|
||||||
|
|
||||||
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
|
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
|
||||||
return account.access_token
|
return account.access_token
|
||||||
|
|
@ -503,10 +633,10 @@ class SocialAccountService:
|
||||||
Returns:
|
Returns:
|
||||||
SocialAccount: 생성된 소셜 계정
|
SocialAccount: 생성된 소셜 계정
|
||||||
"""
|
"""
|
||||||
# 토큰 만료 시간 계산
|
# 토큰 만료 시간 계산 (DB에 naive datetime으로 저장)
|
||||||
token_expires_at = None
|
token_expires_at = None
|
||||||
if token_response.expires_in:
|
if token_response.expires_in:
|
||||||
token_expires_at = datetime.now() + timedelta(
|
token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||||
seconds=token_response.expires_in
|
seconds=token_response.expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -559,7 +689,8 @@ class SocialAccountService:
|
||||||
if token_response.refresh_token:
|
if token_response.refresh_token:
|
||||||
account.refresh_token = token_response.refresh_token
|
account.refresh_token = token_response.refresh_token
|
||||||
if token_response.expires_in:
|
if token_response.expires_in:
|
||||||
account.token_expires_at = datetime.now() + timedelta(
|
# DB에 naive datetime으로 저장
|
||||||
|
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
|
||||||
seconds=token_response.expires_in
|
seconds=token_response.expires_in
|
||||||
)
|
)
|
||||||
if token_response.scope:
|
if token_response.scope:
|
||||||
|
|
@ -575,7 +706,7 @@ class SocialAccountService:
|
||||||
|
|
||||||
# 재연결 시 연결 시간 업데이트
|
# 재연결 시 연결 시간 업데이트
|
||||||
if update_connected_at:
|
if update_connected_at:
|
||||||
account.connected_at = datetime.now()
|
account.connected_at = now().replace(tzinfo=None)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(account)
|
await session.refresh(account)
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ Social Upload Background Task
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
||||||
|
from app.utils.timezone import now
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
@ -19,7 +20,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||||
from config import social_upload_settings
|
from config import social_upload_settings
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
from app.social.exceptions import UploadError, UploadQuotaExceededError
|
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
||||||
from app.social.models import SocialUpload
|
from app.social.models import SocialUpload
|
||||||
from app.social.services import social_account_service
|
from app.social.services import social_account_service
|
||||||
from app.social.uploader import get_uploader
|
from app.social.uploader import get_uploader
|
||||||
|
|
@ -70,7 +71,7 @@ async def _update_upload_status(
|
||||||
if error_message:
|
if error_message:
|
||||||
upload.error_message = error_message
|
upload.error_message = error_message
|
||||||
if status == UploadStatus.COMPLETED:
|
if status == UploadStatus.COMPLETED:
|
||||||
upload.uploaded_at = datetime.now()
|
upload.uploaded_at = now().replace(tzinfo=None)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -352,6 +353,17 @@ async def process_social_upload(upload_id: int) -> None:
|
||||||
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
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:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
|
||||||
|
|
|
||||||
|
|
@ -415,13 +415,6 @@ async def get_song_status(
|
||||||
|
|
||||||
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
# processing 상태인 경우에만 백그라운드 태스크 실행 (중복 방지)
|
||||||
if song and song.status == "processing":
|
if song and song.status == "processing":
|
||||||
# store_name 조회
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == song.project_id)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
store_name = project.store_name if project else "song"
|
|
||||||
|
|
||||||
# 상태를 uploading으로 변경 (중복 호출 방지)
|
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||||
song.status = "uploading"
|
song.status = "uploading"
|
||||||
song.suno_audio_id = first_clip.get("id")
|
song.suno_audio_id = first_clip.get("id")
|
||||||
|
|
@ -435,12 +428,11 @@ async def get_song_status(
|
||||||
download_and_upload_song_by_suno_task_id,
|
download_and_upload_song_by_suno_task_id,
|
||||||
suno_task_id=song_id,
|
suno_task_id=song_id,
|
||||||
audio_url=audio_url,
|
audio_url=audio_url,
|
||||||
store_name=store_name,
|
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
duration=clip_duration,
|
duration=clip_duration,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}"
|
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
suno_audio_id = first_clip.get("id")
|
suno_audio_id = first_clip.get("id")
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
from dataclasses import dataclass, field
|
from typing import Optional
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,21 +104,6 @@ class GenerateSongResponse(BaseModel):
|
||||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
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):
|
class SongClipData(BaseModel):
|
||||||
"""생성된 노래 클립 정보"""
|
"""생성된 노래 클립 정보"""
|
||||||
|
|
||||||
|
|
@ -234,94 +216,3 @@ class PollingSongResponse(BaseModel):
|
||||||
song_result_url: Optional[str] = Field(
|
song_result_url: Optional[str] = Field(
|
||||||
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
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", ""),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ Song Background Tasks
|
||||||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import traceback
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
@ -15,10 +13,8 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.utils.common import generate_task_id
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from config import prj_settings
|
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("song")
|
logger = get_logger("song")
|
||||||
|
|
@ -118,87 +114,23 @@ async def _download_audio(url: str, task_id: str) -> bytes:
|
||||||
return response.content
|
return response.content
|
||||||
|
|
||||||
|
|
||||||
async def download_and_save_song(
|
|
||||||
task_id: str,
|
|
||||||
audio_url: str,
|
|
||||||
store_name: str,
|
|
||||||
) -> None:
|
|
||||||
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 프로젝트 task_id
|
|
||||||
audio_url: 다운로드할 오디오 URL
|
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
"""
|
|
||||||
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
|
||||||
today = date.today().strftime("%Y-%m-%d")
|
|
||||||
unique_id = await generate_task_id()
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
|
||||||
safe_store_name = "".join(
|
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "song"
|
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
|
||||||
# 절대 경로 생성
|
|
||||||
media_dir = Path("media") / "song" / today / unique_id
|
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path = media_dir / file_name
|
|
||||||
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
|
|
||||||
|
|
||||||
# 오디오 파일 다운로드
|
|
||||||
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
|
||||||
|
|
||||||
content = await _download_audio(audio_url, task_id)
|
|
||||||
|
|
||||||
async with aiofiles.open(str(file_path), "wb") as f:
|
|
||||||
await f.write(content)
|
|
||||||
|
|
||||||
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
|
||||||
|
|
||||||
# 프론트엔드에서 접근 가능한 URL 생성
|
|
||||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
|
||||||
base_url = f"{prj_settings.PROJECT_DOMAIN}"
|
|
||||||
file_url = f"{base_url}{relative_path}"
|
|
||||||
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
|
||||||
|
|
||||||
# Song 테이블 업데이트
|
|
||||||
await _update_song_status(task_id, "completed", file_url)
|
|
||||||
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
|
||||||
await _update_song_status(task_id, "failed")
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
|
||||||
await _update_song_status(task_id, "failed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
|
||||||
await _update_song_status(task_id, "failed")
|
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload_song_by_suno_task_id(
|
async def download_and_upload_song_by_suno_task_id(
|
||||||
suno_task_id: str,
|
suno_task_id: str,
|
||||||
audio_url: str,
|
audio_url: str,
|
||||||
store_name: str,
|
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
duration: float | None = None,
|
duration: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
||||||
|
|
||||||
|
파일명은 suno_task_id를 사용하여 고유성을 보장합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
suno_task_id: Suno API 작업 ID
|
suno_task_id: Suno API 작업 ID (파일명으로도 사용)
|
||||||
audio_url: 다운로드할 오디오 URL
|
audio_url: 다운로드할 오디오 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||||
duration: 노래 재생 시간 (초)
|
duration: 노래 재생 시간 (초)
|
||||||
"""
|
"""
|
||||||
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, duration: {duration}")
|
||||||
temp_file_path: Path | None = None
|
temp_file_path: Path | None = None
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
|
|
||||||
|
|
@ -220,12 +152,8 @@ async def download_and_upload_song_by_suno_task_id(
|
||||||
task_id = song.task_id
|
task_id = song.task_id
|
||||||
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
|
||||||
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
# suno_task_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||||
safe_store_name = "".join(
|
file_name = f"{suno_task_id}.mp3"
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "song"
|
|
||||||
file_name = f"{safe_store_name}.mp3"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
# 임시 저장 경로 생성
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||||
|
|
||||||
|
from app.utils.timezone import now
|
||||||
from fastapi.responses import RedirectResponse, Response
|
from fastapi.responses import RedirectResponse, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -22,21 +23,23 @@ logger = logging.getLogger(__name__)
|
||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import RefreshToken, User
|
from app.user.models import RefreshToken, User
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
AccessTokenResponse,
|
|
||||||
KakaoCodeRequest,
|
KakaoCodeRequest,
|
||||||
KakaoLoginResponse,
|
KakaoLoginResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
|
TokenResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from app.user.services import auth_service, kakao_client
|
from app.user.services import auth_service, kakao_client
|
||||||
from app.user.services.jwt import (
|
from app.user.services.jwt import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
get_access_token_expire_seconds,
|
get_access_token_expire_seconds,
|
||||||
get_refresh_token_expires_at,
|
get_refresh_token_expires_at,
|
||||||
get_token_hash,
|
get_token_hash,
|
||||||
)
|
)
|
||||||
|
from app.social.services import social_account_service
|
||||||
from app.utils.common import generate_uuid
|
from app.utils.common import generate_uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -140,6 +143,19 @@ async def kakao_callback(
|
||||||
ip_address=ip_address,
|
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 = (
|
redirect_url = (
|
||||||
f"{prj_settings.PROJECT_DOMAIN}"
|
f"{prj_settings.PROJECT_DOMAIN}"
|
||||||
|
|
@ -205,32 +221,49 @@ async def kakao_verify(
|
||||||
ip_address=ip_address,
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/refresh",
|
"/refresh",
|
||||||
response_model=AccessTokenResponse,
|
response_model=TokenResponse,
|
||||||
summary="토큰 갱신",
|
summary="토큰 갱신 (Refresh Token Rotation)",
|
||||||
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
|
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.",
|
||||||
)
|
)
|
||||||
async def refresh_token(
|
async def refresh_token(
|
||||||
body: RefreshTokenRequest,
|
body: RefreshTokenRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
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,
|
refresh_token=body.refresh_token,
|
||||||
session=session,
|
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(
|
@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(
|
await auth_service.logout(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
refresh_token=body.refresh_token,
|
refresh_token=body.refresh_token,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
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(
|
await auth_service.logout_all(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -434,7 +477,7 @@ async def generate_test_token(
|
||||||
session.add(db_refresh_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()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
"""
|
||||||
|
SocialAccount API 라우터
|
||||||
|
|
||||||
|
소셜 계정 연동 CRUD 엔드포인트를 제공합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.session import get_session
|
||||||
|
from app.user.dependencies import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
|
from app.user.schemas.social_account_schema import (
|
||||||
|
SocialAccountCreateRequest,
|
||||||
|
SocialAccountDeleteResponse,
|
||||||
|
SocialAccountListResponse,
|
||||||
|
SocialAccountResponse,
|
||||||
|
SocialAccountUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.user.services.social_account import SocialAccountService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/social-accounts", tags=["Social Account"])
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 소셜 계정 목록 조회
|
||||||
|
# =============================================================================
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=SocialAccountListResponse,
|
||||||
|
summary="소셜 계정 목록 조회",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
현재 로그인한 사용자의 연동된 소셜 계정 목록을 조회합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필수
|
||||||
|
|
||||||
|
## 반환 정보
|
||||||
|
- **items**: 소셜 계정 목록
|
||||||
|
- **total**: 총 계정 수
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def get_social_accounts(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SocialAccountListResponse:
|
||||||
|
"""소셜 계정 목록 조회"""
|
||||||
|
logger.info(f"[get_social_accounts] START - user_uuid: {current_user.user_uuid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = SocialAccountService(session)
|
||||||
|
accounts = await service.get_list(current_user)
|
||||||
|
|
||||||
|
response = SocialAccountListResponse(
|
||||||
|
items=[SocialAccountResponse.model_validate(acc) for acc in accounts],
|
||||||
|
total=len(accounts),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[get_social_accounts] SUCCESS - user_uuid: {current_user.user_uuid}, count: {len(accounts)}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[get_social_accounts] ERROR - user_uuid: {current_user.user_uuid}, error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="소셜 계정 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 소셜 계정 상세 조회
|
||||||
|
# =============================================================================
|
||||||
|
@router.get(
|
||||||
|
"/{account_id}",
|
||||||
|
response_model=SocialAccountResponse,
|
||||||
|
summary="소셜 계정 상세 조회",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
특정 소셜 계정의 상세 정보를 조회합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필수
|
||||||
|
- 본인 소유의 계정만 조회 가능
|
||||||
|
|
||||||
|
## 경로 파라미터
|
||||||
|
- **account_id**: 소셜 계정 ID
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
404: {"description": "소셜 계정을 찾을 수 없음"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_social_account(
|
||||||
|
account_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SocialAccountResponse:
|
||||||
|
"""소셜 계정 상세 조회"""
|
||||||
|
logger.info(f"[get_social_account] START - user_uuid: {current_user.user_uuid}, account_id: {account_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = SocialAccountService(session)
|
||||||
|
account = await service.get_by_id(current_user, account_id)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
logger.warning(f"[get_social_account] NOT_FOUND - account_id: {account_id}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="소셜 계정을 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[get_social_account] SUCCESS - account_id: {account_id}, platform: {account.platform}")
|
||||||
|
return SocialAccountResponse.model_validate(account)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[get_social_account] ERROR - account_id: {account_id}, error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="소셜 계정 조회 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 소셜 계정 생성
|
||||||
|
# =============================================================================
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=SocialAccountResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="소셜 계정 연동",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
새로운 소셜 계정을 연동합니다.
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필수
|
||||||
|
|
||||||
|
## 요청 본문
|
||||||
|
- **platform**: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
|
||||||
|
- **access_token**: OAuth 액세스 토큰
|
||||||
|
- **platform_user_id**: 플랫폼 내 사용자 고유 ID
|
||||||
|
- 기타 선택 필드
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
- 동일한 플랫폼의 동일한 계정은 중복 연동할 수 없습니다.
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
400: {"description": "이미 연동된 계정"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def create_social_account(
|
||||||
|
data: SocialAccountCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SocialAccountResponse:
|
||||||
|
"""소셜 계정 연동"""
|
||||||
|
logger.info(
|
||||||
|
f"[create_social_account] START - user_uuid: {current_user.user_uuid}, "
|
||||||
|
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = SocialAccountService(session)
|
||||||
|
account = await service.create(current_user, data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[create_social_account] SUCCESS - account_id: {account.id}, "
|
||||||
|
f"platform: {account.platform}"
|
||||||
|
)
|
||||||
|
return SocialAccountResponse.model_validate(account)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"[create_social_account] DUPLICATE - error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[create_social_account] ERROR - error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="소셜 계정 연동 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 소셜 계정 수정
|
||||||
|
# =============================================================================
|
||||||
|
@router.patch(
|
||||||
|
"/{account_id}",
|
||||||
|
response_model=SocialAccountResponse,
|
||||||
|
summary="소셜 계정 정보 수정",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
소셜 계정 정보를 수정합니다. (토큰 갱신 등)
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필수
|
||||||
|
- 본인 소유의 계정만 수정 가능
|
||||||
|
|
||||||
|
## 경로 파라미터
|
||||||
|
- **account_id**: 소셜 계정 ID
|
||||||
|
|
||||||
|
## 요청 본문
|
||||||
|
- 수정할 필드만 전송 (PATCH 방식)
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
404: {"description": "소셜 계정을 찾을 수 없음"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def update_social_account(
|
||||||
|
account_id: int,
|
||||||
|
data: SocialAccountUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SocialAccountResponse:
|
||||||
|
"""소셜 계정 정보 수정"""
|
||||||
|
logger.info(
|
||||||
|
f"[update_social_account] START - user_uuid: {current_user.user_uuid}, "
|
||||||
|
f"account_id: {account_id}, data: {data.model_dump(exclude_unset=True)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = SocialAccountService(session)
|
||||||
|
account = await service.update(current_user, account_id, data)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
logger.warning(f"[update_social_account] NOT_FOUND - account_id: {account_id}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="소셜 계정을 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[update_social_account] SUCCESS - account_id: {account_id}")
|
||||||
|
return SocialAccountResponse.model_validate(account)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[update_social_account] ERROR - account_id: {account_id}, error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="소셜 계정 수정 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 소셜 계정 삭제
|
||||||
|
# =============================================================================
|
||||||
|
@router.delete(
|
||||||
|
"/{account_id}",
|
||||||
|
response_model=SocialAccountDeleteResponse,
|
||||||
|
summary="소셜 계정 연동 해제",
|
||||||
|
description="""
|
||||||
|
## 개요
|
||||||
|
소셜 계정 연동을 해제합니다. (소프트 삭제)
|
||||||
|
|
||||||
|
## 인증
|
||||||
|
- Bearer 토큰 필수
|
||||||
|
- 본인 소유의 계정만 삭제 가능
|
||||||
|
|
||||||
|
## 경로 파라미터
|
||||||
|
- **account_id**: 소셜 계정 ID
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
404: {"description": "소셜 계정을 찾을 수 없음"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def delete_social_account(
|
||||||
|
account_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SocialAccountDeleteResponse:
|
||||||
|
"""소셜 계정 연동 해제"""
|
||||||
|
logger.info(f"[delete_social_account] START - user_uuid: {current_user.user_uuid}, account_id: {account_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = SocialAccountService(session)
|
||||||
|
deleted_id = await service.delete(current_user, account_id)
|
||||||
|
|
||||||
|
if not deleted_id:
|
||||||
|
logger.warning(f"[delete_social_account] NOT_FOUND - account_id: {account_id}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="소셜 계정을 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[delete_social_account] SUCCESS - deleted_id: {deleted_id}")
|
||||||
|
return SocialAccountDeleteResponse(
|
||||||
|
message="소셜 계정이 삭제되었습니다.",
|
||||||
|
deleted_id=deleted_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[delete_social_account] ERROR - account_id: {account_id}, error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="소셜 계정 삭제 중 오류가 발생했습니다.",
|
||||||
|
)
|
||||||
|
|
@ -160,16 +160,17 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
|
|
||||||
column_list = [
|
column_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_id",
|
"user_uuid",
|
||||||
"platform",
|
"platform",
|
||||||
"platform_username",
|
"platform_username",
|
||||||
"is_active",
|
"is_active",
|
||||||
"connected_at",
|
"is_deleted",
|
||||||
|
"created_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
column_details_list = [
|
column_details_list = [
|
||||||
"id",
|
"id",
|
||||||
"user_id",
|
"user_uuid",
|
||||||
"platform",
|
"platform",
|
||||||
"platform_user_id",
|
"platform_user_id",
|
||||||
"platform_username",
|
"platform_username",
|
||||||
|
|
@ -177,32 +178,34 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
"scope",
|
"scope",
|
||||||
"token_expires_at",
|
"token_expires_at",
|
||||||
"is_active",
|
"is_active",
|
||||||
"connected_at",
|
"is_deleted",
|
||||||
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
form_excluded_columns = ["connected_at", "updated_at", "user"]
|
form_excluded_columns = ["created_at", "updated_at", "user"]
|
||||||
|
|
||||||
column_searchable_list = [
|
column_searchable_list = [
|
||||||
SocialAccount.user_id,
|
SocialAccount.user_uuid,
|
||||||
SocialAccount.platform,
|
SocialAccount.platform,
|
||||||
SocialAccount.platform_user_id,
|
SocialAccount.platform_user_id,
|
||||||
SocialAccount.platform_username,
|
SocialAccount.platform_username,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_default_sort = (SocialAccount.connected_at, True)
|
column_default_sort = (SocialAccount.created_at, True)
|
||||||
|
|
||||||
column_sortable_list = [
|
column_sortable_list = [
|
||||||
SocialAccount.id,
|
SocialAccount.id,
|
||||||
SocialAccount.user_id,
|
SocialAccount.user_uuid,
|
||||||
SocialAccount.platform,
|
SocialAccount.platform,
|
||||||
SocialAccount.is_active,
|
SocialAccount.is_active,
|
||||||
SocialAccount.connected_at,
|
SocialAccount.is_deleted,
|
||||||
|
SocialAccount.created_at,
|
||||||
]
|
]
|
||||||
|
|
||||||
column_labels = {
|
column_labels = {
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"user_id": "사용자 ID",
|
"user_uuid": "사용자 UUID",
|
||||||
"platform": "플랫폼",
|
"platform": "플랫폼",
|
||||||
"platform_user_id": "플랫폼 사용자 ID",
|
"platform_user_id": "플랫폼 사용자 ID",
|
||||||
"platform_username": "플랫폼 사용자명",
|
"platform_username": "플랫폼 사용자명",
|
||||||
|
|
@ -210,6 +213,7 @@ class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||||
"scope": "권한 범위",
|
"scope": "권한 범위",
|
||||||
"token_expires_at": "토큰 만료일시",
|
"token_expires_at": "토큰 만료일시",
|
||||||
"is_active": "활성화",
|
"is_active": "활성화",
|
||||||
"connected_at": "연동일시",
|
"is_deleted": "삭제됨",
|
||||||
|
"created_at": "생성일시",
|
||||||
"updated_at": "수정일시",
|
"updated_at": "수정일시",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
|
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
|
@ -12,17 +13,18 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.user.exceptions import (
|
from app.user.models import User
|
||||||
|
from app.user.services.auth import (
|
||||||
AdminRequiredError,
|
AdminRequiredError,
|
||||||
InvalidTokenError,
|
InvalidTokenError,
|
||||||
MissingTokenError,
|
MissingTokenError,
|
||||||
TokenExpiredError,
|
|
||||||
UserInactiveError,
|
UserInactiveError,
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
)
|
)
|
||||||
from app.user.models import User
|
|
||||||
from app.user.services.jwt import decode_token
|
from app.user.services.jwt import decode_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,18 +50,28 @@ async def get_current_user(
|
||||||
UserInactiveError: 비활성화된 계정인 경우
|
UserInactiveError: 비활성화된 계정인 경우
|
||||||
"""
|
"""
|
||||||
if credentials is None:
|
if credentials is None:
|
||||||
|
logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError")
|
||||||
raise 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:
|
if payload is None:
|
||||||
|
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
# 토큰 타입 확인
|
# 토큰 타입 확인
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, "
|
||||||
|
f"got: {payload.get('type')}, sub: {payload.get('sub')}"
|
||||||
|
)
|
||||||
raise InvalidTokenError("액세스 토큰이 아닙니다.")
|
raise InvalidTokenError("액세스 토큰이 아닙니다.")
|
||||||
|
|
||||||
user_uuid = payload.get("sub")
|
user_uuid = payload.get("sub")
|
||||||
if user_uuid is None:
|
if user_uuid is None:
|
||||||
|
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
# 사용자 조회
|
# 사용자 조회
|
||||||
|
|
@ -72,11 +84,18 @@ async def get_current_user(
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
|
logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}")
|
||||||
raise UserNotFoundError()
|
raise UserNotFoundError()
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||||
|
)
|
||||||
raise UserInactiveError()
|
raise UserInactiveError()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,17 +116,24 @@ async def get_current_user_optional(
|
||||||
User | None: 로그인한 사용자 또는 None
|
User | None: 로그인한 사용자 또는 None
|
||||||
"""
|
"""
|
||||||
if credentials is None:
|
if credentials is None:
|
||||||
|
logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
payload = decode_token(credentials.credentials)
|
token = credentials.credentials
|
||||||
|
payload = decode_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
|
logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
|
logger.debug(
|
||||||
|
f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user_uuid = payload.get("sub")
|
user_uuid = payload.get("sub")
|
||||||
if user_uuid is None:
|
if user_uuid is None:
|
||||||
|
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|
@ -119,8 +145,14 @@ async def get_current_user_optional(
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
|
logger.debug(
|
||||||
|
f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
"""
|
|
||||||
User 모듈 커스텀 예외 정의
|
|
||||||
|
|
||||||
인증 및 사용자 관련 에러를 처리하기 위한 예외 클래스들입니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
|
|
||||||
class AuthException(HTTPException):
|
|
||||||
"""인증 관련 기본 예외"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
status_code: int,
|
|
||||||
code: str,
|
|
||||||
message: str,
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status_code,
|
|
||||||
detail={"code": code, "message": message},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 카카오 OAuth 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
class InvalidAuthCodeError(AuthException):
|
|
||||||
"""유효하지 않은 인가 코드"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "유효하지 않은 인가 코드입니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="INVALID_CODE",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class KakaoAuthFailedError(AuthException):
|
|
||||||
"""카카오 인증 실패"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
code="KAKAO_AUTH_FAILED",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class KakaoAPIError(AuthException):
|
|
||||||
"""카카오 API 호출 오류"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
code="KAKAO_API_ERROR",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# JWT 토큰 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
class TokenExpiredError(AuthException):
|
|
||||||
"""토큰 만료"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="TOKEN_EXPIRED",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidTokenError(AuthException):
|
|
||||||
"""유효하지 않은 토큰"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="INVALID_TOKEN",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TokenRevokedError(AuthException):
|
|
||||||
"""취소된 토큰"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="TOKEN_REVOKED",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MissingTokenError(AuthException):
|
|
||||||
"""토큰 누락"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "인증 토큰이 필요합니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
code="MISSING_TOKEN",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 사용자 관련 예외
|
|
||||||
# =============================================================================
|
|
||||||
class UserNotFoundError(AuthException):
|
|
||||||
"""사용자 없음"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
code="USER_NOT_FOUND",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserInactiveError(AuthException):
|
|
||||||
"""비활성화된 계정"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
code="USER_INACTIVE",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AdminRequiredError(AuthException):
|
|
||||||
"""관리자 권한 필요"""
|
|
||||||
|
|
||||||
def __init__(self, message: str = "관리자 권한이 필요합니다."):
|
|
||||||
super().__init__(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
code="ADMIN_REQUIRED",
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
@ -5,6 +5,7 @@ User 모듈 SQLAlchemy 모델 정의
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, func
|
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
|
|
@ -13,6 +14,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
|
|
||||||
|
|
@ -342,7 +344,6 @@ class RefreshToken(Base):
|
||||||
token_hash: Mapped[str] = mapped_column(
|
token_hash: Mapped[str] = mapped_column(
|
||||||
String(64),
|
String(64),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
unique=True,
|
|
||||||
comment="리프레시 토큰 SHA-256 해시값",
|
comment="리프레시 토큰 SHA-256 해시값",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -390,6 +391,7 @@ class RefreshToken(Base):
|
||||||
user: Mapped["User"] = relationship(
|
user: Mapped["User"] = relationship(
|
||||||
"User",
|
"User",
|
||||||
back_populates="refresh_tokens",
|
back_populates="refresh_tokens",
|
||||||
|
lazy="selectin", # lazy loading 방지
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|
@ -403,6 +405,15 @@ class RefreshToken(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Platform(str, Enum):
|
||||||
|
"""소셜 플랫폼 구분"""
|
||||||
|
|
||||||
|
YOUTUBE = "youtube"
|
||||||
|
INSTAGRAM = "instagram"
|
||||||
|
FACEBOOK = "facebook"
|
||||||
|
TIKTOK = "tiktok"
|
||||||
|
|
||||||
|
|
||||||
class SocialAccount(Base):
|
class SocialAccount(Base):
|
||||||
"""
|
"""
|
||||||
소셜 계정 연동 테이블
|
소셜 계정 연동 테이블
|
||||||
|
|
@ -475,10 +486,10 @@ class SocialAccount(Base):
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 플랫폼 구분
|
# 플랫폼 구분
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
platform: Mapped[str] = mapped_column(
|
platform: Mapped[Platform] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
comment="플랫폼 구분 (youtube, instagram, facebook)",
|
comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|
@ -511,9 +522,9 @@ class SocialAccount(Base):
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 플랫폼 계정 식별 정보
|
# 플랫폼 계정 식별 정보
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
platform_user_id: Mapped[str] = mapped_column(
|
platform_user_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(100),
|
String(100),
|
||||||
nullable=False,
|
nullable=True,
|
||||||
comment="플랫폼 내 사용자 고유 ID",
|
comment="플랫폼 내 사용자 고유 ID",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -539,7 +550,7 @@ class SocialAccount(Base):
|
||||||
Boolean,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=True,
|
default=True,
|
||||||
comment="연동 활성화 상태 (비활성화 시 사용 중지)",
|
comment="활성화 상태 (비활성화 시 사용 중지)",
|
||||||
)
|
)
|
||||||
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
|
|
@ -554,9 +565,10 @@ class SocialAccount(Base):
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
connected_at: Mapped[datetime] = mapped_column(
|
connected_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=True,
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
comment="연동 일시",
|
onupdate=func.now(),
|
||||||
|
comment="연결 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|
@ -567,12 +579,20 @@ class SocialAccount(Base):
|
||||||
comment="정보 수정 일시",
|
comment="정보 수정 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
comment="생성 일시",
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# User 관계
|
# User 관계
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
user: Mapped["User"] = relationship(
|
user: Mapped["User"] = relationship(
|
||||||
"User",
|
"User",
|
||||||
back_populates="social_accounts",
|
back_populates="social_accounts",
|
||||||
|
lazy="selectin", # lazy loading 방지
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
AccessTokenResponse,
|
|
||||||
KakaoCodeRequest,
|
KakaoCodeRequest,
|
||||||
KakaoLoginResponse,
|
KakaoLoginResponse,
|
||||||
KakaoTokenResponse,
|
KakaoTokenResponse,
|
||||||
|
|
@ -12,7 +11,6 @@ from app.user.schemas.user_schema import (
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AccessTokenResponse",
|
|
||||||
"KakaoCodeRequest",
|
"KakaoCodeRequest",
|
||||||
"KakaoLoginResponse",
|
"KakaoLoginResponse",
|
||||||
"KakaoTokenResponse",
|
"KakaoTokenResponse",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""
|
||||||
|
SocialAccount 모듈 Pydantic 스키마 정의
|
||||||
|
|
||||||
|
소셜 계정 연동 API 요청/응답 검증을 위한 스키마들입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.user.models import Platform
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 요청 스키마
|
||||||
|
# =============================================================================
|
||||||
|
class SocialAccountCreateRequest(BaseModel):
|
||||||
|
"""소셜 계정 연동 요청"""
|
||||||
|
|
||||||
|
platform: Platform = Field(..., description="플랫폼 구분 (youtube, instagram, facebook, tiktok)")
|
||||||
|
access_token: str = Field(..., min_length=1, description="OAuth 액세스 토큰")
|
||||||
|
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
||||||
|
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||||
|
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||||
|
platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID")
|
||||||
|
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||||
|
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"platform": "instagram",
|
||||||
|
"access_token": "IGQWRPcG...",
|
||||||
|
"refresh_token": None,
|
||||||
|
"token_expires_at": None,
|
||||||
|
"scope": None,
|
||||||
|
"platform_user_id": None,
|
||||||
|
"platform_username": None,
|
||||||
|
"platform_data": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountUpdateRequest(BaseModel):
|
||||||
|
"""소셜 계정 정보 수정 요청"""
|
||||||
|
|
||||||
|
access_token: Optional[str] = Field(None, min_length=1, description="OAuth 액세스 토큰")
|
||||||
|
refresh_token: Optional[str] = Field(None, description="OAuth 리프레시 토큰")
|
||||||
|
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||||
|
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||||
|
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||||
|
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||||
|
is_active: Optional[bool] = Field(None, description="활성화 상태")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"access_token": "IGQWRPcG_NEW_TOKEN...",
|
||||||
|
"token_expires_at": "2026-04-15T10:30:00",
|
||||||
|
"is_active": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 응답 스키마
|
||||||
|
# =============================================================================
|
||||||
|
class SocialAccountResponse(BaseModel):
|
||||||
|
"""소셜 계정 정보 응답"""
|
||||||
|
|
||||||
|
account_id: int = Field(..., validation_alias="id", description="소셜 계정 ID")
|
||||||
|
platform: Platform = Field(..., description="플랫폼 구분")
|
||||||
|
platform_user_id: Optional[str] = Field(None, description="플랫폼 내 사용자 고유 ID")
|
||||||
|
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명/핸들")
|
||||||
|
platform_data: Optional[dict[str, Any]] = Field(None, description="플랫폼별 추가 정보")
|
||||||
|
scope: Optional[str] = Field(None, description="허용된 권한 범위")
|
||||||
|
token_expires_at: Optional[datetime] = Field(None, description="토큰 만료 일시")
|
||||||
|
is_active: bool = Field(..., description="활성화 상태")
|
||||||
|
created_at: datetime = Field(..., description="연동 일시")
|
||||||
|
updated_at: datetime = Field(..., description="수정 일시")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"from_attributes": True,
|
||||||
|
"populate_by_name": True,
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"account_id": 1,
|
||||||
|
"platform": "instagram",
|
||||||
|
"platform_user_id": "17841400000000000",
|
||||||
|
"platform_username": "my_instagram_account",
|
||||||
|
"platform_data": {
|
||||||
|
"business_account_id": "17841400000000000"
|
||||||
|
},
|
||||||
|
"scope": "instagram_basic,instagram_content_publish",
|
||||||
|
"token_expires_at": "2026-03-15T10:30:00",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": "2026-01-15T10:30:00",
|
||||||
|
"updated_at": "2026-01-15T10:30:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountListResponse(BaseModel):
|
||||||
|
"""소셜 계정 목록 응답"""
|
||||||
|
|
||||||
|
items: list[SocialAccountResponse] = Field(..., description="소셜 계정 목록")
|
||||||
|
total: int = Field(..., description="총 계정 수")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"account_id": 1,
|
||||||
|
"platform": "instagram",
|
||||||
|
"platform_user_id": "17841400000000000",
|
||||||
|
"platform_username": "my_instagram_account",
|
||||||
|
"platform_data": None,
|
||||||
|
"scope": "instagram_basic",
|
||||||
|
"token_expires_at": "2026-03-15T10:30:00",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": "2026-01-15T10:30:00",
|
||||||
|
"updated_at": "2026-01-15T10:30:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountDeleteResponse(BaseModel):
|
||||||
|
"""소셜 계정 삭제 응답"""
|
||||||
|
|
||||||
|
message: str = Field(..., description="결과 메시지")
|
||||||
|
deleted_id: int = Field(..., description="삭제된 계정 ID")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"message": "소셜 계정이 삭제되었습니다.",
|
||||||
|
"deleted_id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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):
|
class RefreshTokenRequest(BaseModel):
|
||||||
"""토큰 갱신 요청"""
|
"""토큰 갱신 요청"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from app.utils.timezone import now
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -16,19 +18,72 @@ from config import prj_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.user.exceptions import (
|
|
||||||
InvalidTokenError,
|
# =============================================================================
|
||||||
TokenExpiredError,
|
# 인증 예외 클래스 정의
|
||||||
TokenRevokedError,
|
# =============================================================================
|
||||||
UserInactiveError,
|
class AuthException(HTTPException):
|
||||||
UserNotFoundError,
|
"""인증 관련 기본 예외"""
|
||||||
)
|
|
||||||
|
def __init__(self, status_code: int, code: str, message: str):
|
||||||
|
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
class TokenExpiredError(AuthException):
|
||||||
|
"""토큰 만료"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
|
||||||
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_EXPIRED", message)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTokenError(AuthException):
|
||||||
|
"""유효하지 않은 토큰"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
|
||||||
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "INVALID_TOKEN", message)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRevokedError(AuthException):
|
||||||
|
"""취소된 토큰"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
|
||||||
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "TOKEN_REVOKED", message)
|
||||||
|
|
||||||
|
|
||||||
|
class MissingTokenError(AuthException):
|
||||||
|
"""토큰 누락"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "인증 토큰이 필요합니다."):
|
||||||
|
super().__init__(status.HTTP_401_UNAUTHORIZED, "MISSING_TOKEN", message)
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotFoundError(AuthException):
|
||||||
|
"""사용자 없음"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
|
||||||
|
super().__init__(status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", message)
|
||||||
|
|
||||||
|
|
||||||
|
class UserInactiveError(AuthException):
|
||||||
|
"""비활성화된 계정"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
|
||||||
|
super().__init__(status.HTTP_403_FORBIDDEN, "USER_INACTIVE", message)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminRequiredError(AuthException):
|
||||||
|
"""관리자 권한 필요"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "관리자 권한이 필요합니다."):
|
||||||
|
super().__init__(status.HTTP_403_FORBIDDEN, "ADMIN_REQUIRED", message)
|
||||||
|
|
||||||
|
|
||||||
from app.user.models import RefreshToken, User
|
from app.user.models import RefreshToken, User
|
||||||
from app.utils.common import generate_uuid
|
from app.utils.common import generate_uuid
|
||||||
from app.user.schemas.user_schema import (
|
from app.user.schemas.user_schema import (
|
||||||
AccessTokenResponse,
|
|
||||||
KakaoUserInfo,
|
KakaoUserInfo,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
TokenResponse,
|
||||||
)
|
)
|
||||||
from app.user.services.jwt import (
|
from app.user.services.jwt import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
|
|
@ -113,7 +168,7 @@ class AuthService:
|
||||||
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
|
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
|
||||||
|
|
||||||
# 7. 마지막 로그인 시간 업데이트
|
# 7. 마지막 로그인 시간 업데이트
|
||||||
user.last_login_at = datetime.now()
|
user.last_login_at = now().replace(tzinfo=None)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
|
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
|
||||||
|
|
@ -133,59 +188,129 @@ class AuthService:
|
||||||
self,
|
self,
|
||||||
refresh_token: str,
|
refresh_token: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> AccessTokenResponse:
|
) -> TokenResponse:
|
||||||
"""
|
"""
|
||||||
리프레시 토큰으로 액세스 토큰 갱신
|
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation)
|
||||||
|
|
||||||
|
기존 리프레시 토큰을 폐기하고, 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다.
|
||||||
|
사용자가 서비스를 지속 사용하는 한 세션이 자동 유지됩니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
refresh_token: 리프레시 토큰
|
refresh_token: 리프레시 토큰
|
||||||
session: DB 세션
|
session: DB 세션
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AccessTokenResponse: 새 액세스 토큰
|
TokenResponse: 새 액세스 토큰 + 새 리프레시 토큰
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InvalidTokenError: 토큰이 유효하지 않은 경우
|
InvalidTokenError: 토큰이 유효하지 않은 경우
|
||||||
TokenExpiredError: 토큰이 만료된 경우
|
TokenExpiredError: 토큰이 만료된 경우
|
||||||
TokenRevokedError: 토큰이 폐기된 경우
|
TokenRevokedError: 토큰이 폐기된 경우
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}")
|
||||||
|
|
||||||
# 1. 토큰 디코딩 및 검증
|
# 1. 토큰 디코딩 및 검증
|
||||||
payload = decode_token(refresh_token)
|
payload = decode_token(refresh_token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
|
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, "
|
||||||
|
f"sub: {payload.get('sub')}"
|
||||||
|
)
|
||||||
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
|
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, "
|
||||||
|
f"exp: {payload.get('exp')}"
|
||||||
|
)
|
||||||
|
|
||||||
# 2. DB에서 리프레시 토큰 조회
|
# 2. DB에서 리프레시 토큰 조회
|
||||||
token_hash = get_token_hash(refresh_token)
|
token_hash = get_token_hash(refresh_token)
|
||||||
db_token = await self._get_refresh_token_by_hash(token_hash, session)
|
db_token = await self._get_refresh_token_by_hash(token_hash, session)
|
||||||
|
|
||||||
if db_token is None:
|
if db_token is None:
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, "
|
||||||
|
f"token_hash: {token_hash[:16]}..."
|
||||||
|
)
|
||||||
raise InvalidTokenError()
|
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. 토큰 상태 확인
|
# 3. 토큰 상태 확인
|
||||||
if db_token.is_revoked:
|
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()
|
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()
|
raise TokenExpiredError()
|
||||||
|
|
||||||
# 4. 사용자 확인
|
# 5. 사용자 확인
|
||||||
user_uuid = payload.get("sub")
|
user_uuid = payload.get("sub")
|
||||||
user = await self._get_user_by_uuid(user_uuid, session)
|
user = await self._get_user_by_uuid(user_uuid, session)
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}"
|
||||||
|
)
|
||||||
raise UserNotFoundError()
|
raise UserNotFoundError()
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, "
|
||||||
|
f"user_id: {user.id}"
|
||||||
|
)
|
||||||
raise UserInactiveError()
|
raise UserInactiveError()
|
||||||
|
|
||||||
# 5. 새 액세스 토큰 발급
|
# 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음)
|
||||||
new_access_token = create_access_token(user.user_uuid)
|
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,
|
access_token=new_access_token,
|
||||||
|
refresh_token=new_refresh_token,
|
||||||
token_type="Bearer",
|
token_type="Bearer",
|
||||||
expires_in=get_access_token_expire_seconds(),
|
expires_in=get_access_token_expire_seconds(),
|
||||||
)
|
)
|
||||||
|
|
@ -205,7 +330,12 @@ class AuthService:
|
||||||
session: DB 세션
|
session: DB 세션
|
||||||
"""
|
"""
|
||||||
token_hash = get_token_hash(refresh_token)
|
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)
|
await self._revoke_refresh_token_by_hash(token_hash, session)
|
||||||
|
logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}")
|
||||||
|
|
||||||
async def logout_all(
|
async def logout_all(
|
||||||
self,
|
self,
|
||||||
|
|
@ -219,7 +349,9 @@ class AuthService:
|
||||||
user_id: 사용자 ID
|
user_id: 사용자 ID
|
||||||
session: DB 세션
|
session: DB 세션
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}")
|
||||||
await self._revoke_all_user_tokens(user_id, session)
|
await self._revoke_all_user_tokens(user_id, session)
|
||||||
|
logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}")
|
||||||
|
|
||||||
async def _get_or_create_user(
|
async def _get_or_create_user(
|
||||||
self,
|
self,
|
||||||
|
|
@ -349,6 +481,11 @@ class AuthService:
|
||||||
)
|
)
|
||||||
session.add(refresh_token)
|
session.add(refresh_token)
|
||||||
await session.flush()
|
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
|
return refresh_token
|
||||||
|
|
||||||
async def _get_refresh_token_by_hash(
|
async def _get_refresh_token_by_hash(
|
||||||
|
|
@ -428,7 +565,7 @@ class AuthService:
|
||||||
.where(RefreshToken.token_hash == token_hash)
|
.where(RefreshToken.token_hash == token_hash)
|
||||||
.values(
|
.values(
|
||||||
is_revoked=True,
|
is_revoked=True,
|
||||||
revoked_at=datetime.now(),
|
revoked_at=now().replace(tzinfo=None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -453,7 +590,7 @@ class AuthService:
|
||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
is_revoked=True,
|
is_revoked=True,
|
||||||
revoked_at=datetime.now(),
|
revoked_at=now().replace(tzinfo=None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,18 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
|
||||||
|
|
||||||
|
from app.utils.timezone import now
|
||||||
from config import jwt_settings
|
from config import jwt_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user_uuid: str) -> str:
|
def create_access_token(user_uuid: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -23,7 +28,7 @@ def create_access_token(user_uuid: str) -> str:
|
||||||
Returns:
|
Returns:
|
||||||
JWT 액세스 토큰 문자열
|
JWT 액세스 토큰 문자열
|
||||||
"""
|
"""
|
||||||
expire = datetime.now() + timedelta(
|
expire = now() + timedelta(
|
||||||
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
)
|
)
|
||||||
to_encode = {
|
to_encode = {
|
||||||
|
|
@ -31,11 +36,16 @@ def create_access_token(user_uuid: str) -> str:
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"type": "access",
|
"type": "access",
|
||||||
}
|
}
|
||||||
return jwt.encode(
|
token = jwt.encode(
|
||||||
to_encode,
|
to_encode,
|
||||||
jwt_settings.JWT_SECRET,
|
jwt_settings.JWT_SECRET,
|
||||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
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:
|
def create_refresh_token(user_uuid: str) -> str:
|
||||||
|
|
@ -48,7 +58,7 @@ def create_refresh_token(user_uuid: str) -> str:
|
||||||
Returns:
|
Returns:
|
||||||
JWT 리프레시 토큰 문자열
|
JWT 리프레시 토큰 문자열
|
||||||
"""
|
"""
|
||||||
expire = datetime.now() + timedelta(
|
expire = now() + timedelta(
|
||||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
)
|
)
|
||||||
to_encode = {
|
to_encode = {
|
||||||
|
|
@ -56,11 +66,16 @@ def create_refresh_token(user_uuid: str) -> str:
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
}
|
}
|
||||||
return jwt.encode(
|
token = jwt.encode(
|
||||||
to_encode,
|
to_encode,
|
||||||
jwt_settings.JWT_SECRET,
|
jwt_settings.JWT_SECRET,
|
||||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
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]:
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
|
|
@ -79,8 +94,25 @@ def decode_token(token: str) -> Optional[dict]:
|
||||||
jwt_settings.JWT_SECRET,
|
jwt_settings.JWT_SECRET,
|
||||||
algorithms=[jwt_settings.JWT_ALGORITHM],
|
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
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,7 +138,7 @@ def get_refresh_token_expires_at() -> datetime:
|
||||||
Returns:
|
Returns:
|
||||||
리프레시 토큰 만료 datetime (로컬 시간)
|
리프레시 토큰 만료 datetime (로컬 시간)
|
||||||
"""
|
"""
|
||||||
return datetime.now() + timedelta(
|
return now().replace(tzinfo=None) + timedelta(
|
||||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,39 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
from config import kakao_settings
|
from config import kakao_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
|
|
||||||
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
|
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 카카오 OAuth 예외 클래스 정의
|
||||||
|
# =============================================================================
|
||||||
|
class KakaoException(HTTPException):
|
||||||
|
"""카카오 관련 기본 예외"""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, code: str, message: str):
|
||||||
|
super().__init__(status_code=status_code, detail={"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoAuthFailedError(KakaoException):
|
||||||
|
"""카카오 인증 실패"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
|
||||||
|
super().__init__(status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED", message)
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoAPIError(KakaoException):
|
||||||
|
"""카카오 API 호출 오류"""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
|
||||||
|
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "KAKAO_API_ERROR", message)
|
||||||
|
|
||||||
|
|
||||||
class KakaoOAuthClient:
|
class KakaoOAuthClient:
|
||||||
"""
|
"""
|
||||||
카카오 OAuth API 클라이언트
|
카카오 OAuth API 클라이언트
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""
|
||||||
|
SocialAccount 서비스 레이어
|
||||||
|
|
||||||
|
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.user.models import Platform, SocialAccount, User
|
||||||
|
from app.user.schemas.social_account_schema import (
|
||||||
|
SocialAccountCreateRequest,
|
||||||
|
SocialAccountUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountService:
|
||||||
|
"""소셜 계정 서비스"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get_list(self, user: User) -> list[SocialAccount]:
|
||||||
|
"""
|
||||||
|
사용자의 소셜 계정 목록 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 현재 로그인한 사용자
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[SocialAccount]: 소셜 계정 목록
|
||||||
|
"""
|
||||||
|
logger.debug(f"[SocialAccountService.get_list] START - user_uuid: {user.user_uuid}")
|
||||||
|
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
and_(
|
||||||
|
SocialAccount.user_uuid == user.user_uuid,
|
||||||
|
SocialAccount.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
).order_by(SocialAccount.created_at.desc())
|
||||||
|
)
|
||||||
|
accounts = list(result.scalars().all())
|
||||||
|
|
||||||
|
logger.debug(f"[SocialAccountService.get_list] SUCCESS - count: {len(accounts)}")
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
async def get_by_id(self, user: User, account_id: int) -> Optional[SocialAccount]:
|
||||||
|
"""
|
||||||
|
ID로 소셜 계정 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 현재 로그인한 사용자
|
||||||
|
account_id: 소셜 계정 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SocialAccount | None: 소셜 계정 또는 None
|
||||||
|
"""
|
||||||
|
logger.debug(f"[SocialAccountService.get_by_id] START - user_uuid: {user.user_uuid}, account_id: {account_id}")
|
||||||
|
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
and_(
|
||||||
|
SocialAccount.id == account_id,
|
||||||
|
SocialAccount.user_uuid == user.user_uuid,
|
||||||
|
SocialAccount.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
account = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if account:
|
||||||
|
logger.debug(f"[SocialAccountService.get_by_id] SUCCESS - platform: {account.platform}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[SocialAccountService.get_by_id] NOT_FOUND - account_id: {account_id}")
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
async def get_by_platform(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
platform: Platform,
|
||||||
|
platform_user_id: Optional[str] = None,
|
||||||
|
) -> Optional[SocialAccount]:
|
||||||
|
"""
|
||||||
|
플랫폼별 소셜 계정 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 현재 로그인한 사용자
|
||||||
|
platform: 플랫폼
|
||||||
|
platform_user_id: 플랫폼 사용자 ID (선택)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SocialAccount | None: 소셜 계정 또는 None
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[SocialAccountService.get_by_platform] START - user_uuid: {user.user_uuid}, "
|
||||||
|
f"platform: {platform}, platform_user_id: {platform_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
SocialAccount.user_uuid == user.user_uuid,
|
||||||
|
SocialAccount.platform == platform,
|
||||||
|
SocialAccount.is_deleted == False, # noqa: E712
|
||||||
|
]
|
||||||
|
|
||||||
|
if platform_user_id:
|
||||||
|
conditions.append(SocialAccount.platform_user_id == platform_user_id)
|
||||||
|
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(SocialAccount).where(and_(*conditions))
|
||||||
|
)
|
||||||
|
account = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if account:
|
||||||
|
logger.debug(f"[SocialAccountService.get_by_platform] SUCCESS - id: {account.id}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[SocialAccountService.get_by_platform] NOT_FOUND")
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
data: SocialAccountCreateRequest,
|
||||||
|
) -> SocialAccount:
|
||||||
|
"""
|
||||||
|
소셜 계정 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 현재 로그인한 사용자
|
||||||
|
data: 생성 요청 데이터
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SocialAccount: 생성된 소셜 계정
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 이미 연동된 계정이 존재하는 경우
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[SocialAccountService.create] START - user_uuid: {user.user_uuid}, "
|
||||||
|
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 중복 확인
|
||||||
|
existing = await self.get_by_platform(user, data.platform, data.platform_user_id)
|
||||||
|
if existing:
|
||||||
|
logger.warning(
|
||||||
|
f"[SocialAccountService.create] DUPLICATE - "
|
||||||
|
f"platform: {data.platform}, platform_user_id: {data.platform_user_id}"
|
||||||
|
)
|
||||||
|
raise ValueError(f"이미 연동된 {data.platform.value} 계정입니다.")
|
||||||
|
|
||||||
|
account = SocialAccount(
|
||||||
|
user_uuid=user.user_uuid,
|
||||||
|
platform=data.platform,
|
||||||
|
access_token=data.access_token,
|
||||||
|
refresh_token=data.refresh_token,
|
||||||
|
token_expires_at=data.token_expires_at,
|
||||||
|
scope=data.scope,
|
||||||
|
platform_user_id=data.platform_user_id,
|
||||||
|
platform_username=data.platform_username,
|
||||||
|
platform_data=data.platform_data,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.session.add(account)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(account)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[SocialAccountService.create] SUCCESS - id: {account.id}, "
|
||||||
|
f"platform: {account.platform}, platform_username: {account.platform_username}"
|
||||||
|
)
|
||||||
|
return account
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
account_id: int,
|
||||||
|
data: SocialAccountUpdateRequest,
|
||||||
|
) -> Optional[SocialAccount]:
|
||||||
|
"""
|
||||||
|
소셜 계정 수정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 현재 로그인한 사용자
|
||||||
|
account_id: 소셜 계정 ID
|
||||||
|
data: 수정 요청 데이터
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SocialAccount | None: 수정된 소셜 계정 또는 None
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[SocialAccountService.update] START - user_uuid: {user.user_uuid}, account_id: {account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
account = await self.get_by_id(user, account_id)
|
||||||
|
if not account:
|
||||||
|
logger.warning(f"[SocialAccountService.update] NOT_FOUND - account_id: {account_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 변경된 필드만 업데이트
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if value is not None:
|
||||||
|
setattr(account, field, value)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(account)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[SocialAccountService.update] SUCCESS - id: {account.id}, "
|
||||||
|
f"updated_fields: {list(update_data.keys())}"
|
||||||
|
)
|
||||||
|
return account
|
||||||
|
|
||||||
|
async def delete(self, user: User, account_id: int) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
소셜 계정 소프트 삭제
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 현재 로그인한 사용자
|
||||||
|
account_id: 소셜 계정 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int | None: 삭제된 계정 ID 또는 None
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[SocialAccountService.delete] START - user_uuid: {user.user_uuid}, account_id: {account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
account = await self.get_by_id(user, account_id)
|
||||||
|
if not account:
|
||||||
|
logger.warning(f"[SocialAccountService.delete] NOT_FOUND - account_id: {account_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
account.is_deleted = True
|
||||||
|
account.is_active = False
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[SocialAccountService.delete] SUCCESS - id: {account_id}, platform: {account.platform}"
|
||||||
|
)
|
||||||
|
return account_id
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 의존성 주입용 함수
|
||||||
|
# =============================================================================
|
||||||
|
async def get_social_account_service(session: AsyncSession) -> SocialAccountService:
|
||||||
|
"""SocialAccountService 인스턴스 반환"""
|
||||||
|
return SocialAccountService(session)
|
||||||
|
|
@ -82,7 +82,7 @@ class ChatgptService:
|
||||||
self,
|
self,
|
||||||
prompt : Prompt,
|
prompt : Prompt,
|
||||||
input_data : dict,
|
input_data : dict,
|
||||||
) -> str:
|
) -> BaseModel:
|
||||||
prompt_text = prompt.build_prompt(input_data)
|
prompt_text = prompt.build_prompt(input_data)
|
||||||
|
|
||||||
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
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 = {
|
text_template_h_1 = {
|
||||||
"type": "composition",
|
"type": "composition",
|
||||||
"track": 3,
|
"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:
|
async def get_shared_client() -> httpx.AsyncClient:
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||||
global _shared_client
|
global _shared_client
|
||||||
|
|
@ -365,6 +439,7 @@ class CreatomateService:
|
||||||
image_url_list: list[str],
|
image_url_list: list[str],
|
||||||
lyric: str,
|
lyric: str,
|
||||||
music_url: str,
|
music_url: str,
|
||||||
|
address: str = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
||||||
|
|
||||||
|
|
@ -391,9 +466,8 @@ class CreatomateService:
|
||||||
idx % len(image_url_list)
|
idx % len(image_url_list)
|
||||||
]
|
]
|
||||||
case "text":
|
case "text":
|
||||||
modifications[template_component_name] = lyric_splited[
|
if "address_input" in template_component_name:
|
||||||
idx % len(lyric_splited)
|
modifications[template_component_name] = address
|
||||||
]
|
|
||||||
|
|
||||||
modifications["audio-music"] = music_url
|
modifications["audio-music"] = music_url
|
||||||
|
|
||||||
|
|
@ -405,12 +479,11 @@ class CreatomateService:
|
||||||
image_url_list: list[str],
|
image_url_list: list[str],
|
||||||
lyric: str,
|
lyric: str,
|
||||||
music_url: str,
|
music_url: str,
|
||||||
|
address: str = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
|
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
|
||||||
template_component_data = self.parse_template_component_name(elements)
|
template_component_data = self.parse_template_component_name(elements)
|
||||||
|
|
||||||
lyric = lyric.replace("\r", "")
|
|
||||||
lyric_splited = lyric.split("\n")
|
|
||||||
modifications = {}
|
modifications = {}
|
||||||
|
|
||||||
for idx, (template_component_name, template_type) in enumerate(
|
for idx, (template_component_name, template_type) in enumerate(
|
||||||
|
|
@ -422,9 +495,8 @@ class CreatomateService:
|
||||||
idx % len(image_url_list)
|
idx % len(image_url_list)
|
||||||
]
|
]
|
||||||
case "text":
|
case "text":
|
||||||
modifications[template_component_name] = lyric_splited[
|
if "address_input" in template_component_name:
|
||||||
idx % len(lyric_splited)
|
modifications[template_component_name] = address
|
||||||
]
|
|
||||||
|
|
||||||
modifications["audio-music"] = music_url
|
modifications["audio-music"] = music_url
|
||||||
|
|
||||||
|
|
@ -443,7 +515,8 @@ class CreatomateService:
|
||||||
case "video":
|
case "video":
|
||||||
element["source"] = modification[element["name"]]
|
element["source"] = modification[element["name"]]
|
||||||
case "text":
|
case "text":
|
||||||
element["source"] = modification.get(element["name"], "")
|
#element["source"] = modification[element["name"]]
|
||||||
|
element["text"] = modification.get(element["name"], "")
|
||||||
case "composition":
|
case "composition":
|
||||||
for minor in element["elements"]:
|
for minor in element["elements"]:
|
||||||
recursive_modify(minor)
|
recursive_modify(minor)
|
||||||
|
|
@ -661,8 +734,8 @@ class CreatomateService:
|
||||||
|
|
||||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||||
target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
||||||
template["duration"] = target_duration
|
target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||||
total_template_duration = self.calc_scene_duration(template)
|
total_template_duration = self.calc_scene_duration(template)
|
||||||
extend_rate = target_duration / total_template_duration
|
extend_rate = target_duration / total_template_duration
|
||||||
new_template = copy.deepcopy(template)
|
new_template = copy.deepcopy(template)
|
||||||
|
|
@ -684,7 +757,7 @@ class CreatomateService:
|
||||||
|
|
||||||
return new_template
|
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
|
duration = end_sec - start_sec
|
||||||
text_scene = copy.deepcopy(text_template)
|
text_scene = copy.deepcopy(text_template)
|
||||||
text_scene["name"] = f"Caption-{lyric_index}"
|
text_scene["name"] = f"Caption-{lyric_index}"
|
||||||
|
|
@ -692,13 +765,24 @@ class CreatomateService:
|
||||||
text_scene["time"] = start_sec
|
text_scene["time"] = start_sec
|
||||||
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
|
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
|
||||||
text_scene["elements"][0]["text"] = lyric_text
|
text_scene["elements"][0]["text"] = lyric_text
|
||||||
|
text_scene["elements"][0]["font_family"] = font_family
|
||||||
return text_scene
|
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):
|
def get_text_template(self):
|
||||||
match self.orientation:
|
match self.orientation:
|
||||||
case "vertical":
|
case "vertical":
|
||||||
return text_template_v_2
|
return text_template_v_3
|
||||||
case "horizontal":
|
case "horizontal":
|
||||||
return text_template_h_1
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API Client
|
||||||
|
|
||||||
|
Instagram Graph API를 사용한 비디오/릴스 게시를 위한 비동기 클라이언트입니다.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with InstagramClient(access_token="YOUR_TOKEN") as client:
|
||||||
|
media = await client.publish_video(
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
caption="Hello Instagram!"
|
||||||
|
)
|
||||||
|
print(f"게시 완료: {media.permalink}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.sns.schemas.sns_schema import ErrorResponse, Media, MediaContainer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Error State & Parser
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorState(str, Enum):
|
||||||
|
"""Instagram API 에러 상태"""
|
||||||
|
|
||||||
|
RATE_LIMIT = "rate_limit"
|
||||||
|
AUTH_ERROR = "auth_error"
|
||||||
|
CONTAINER_TIMEOUT = "container_timeout"
|
||||||
|
CONTAINER_ERROR = "container_error"
|
||||||
|
API_ERROR = "api_error"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_instagram_error(e: Exception) -> tuple[ErrorState, str, dict]:
|
||||||
|
"""
|
||||||
|
Instagram 예외를 파싱하여 상태, 메시지, 추가 정보를 반환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
e: 발생한 예외
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (error_state, message, extra_info)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> error_state, message, extra_info = parse_instagram_error(e)
|
||||||
|
>>> if error_state == ErrorState.RATE_LIMIT:
|
||||||
|
... retry_after = extra_info.get("retry_after", 60)
|
||||||
|
"""
|
||||||
|
error_str = str(e)
|
||||||
|
extra_info = {}
|
||||||
|
|
||||||
|
# Rate Limit 에러
|
||||||
|
if "[RateLimit]" in error_str:
|
||||||
|
match = re.search(r"retry_after=(\d+)s", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["retry_after"] = int(match.group(1))
|
||||||
|
return ErrorState.RATE_LIMIT, "API 호출 제한 초과", extra_info
|
||||||
|
|
||||||
|
# 인증 에러 (code=190)
|
||||||
|
if "code=190" in error_str:
|
||||||
|
return ErrorState.AUTH_ERROR, "인증 실패 (토큰 만료 또는 무효)", extra_info
|
||||||
|
|
||||||
|
# 컨테이너 타임아웃
|
||||||
|
if "[ContainerTimeout]" in error_str:
|
||||||
|
match = re.search(r"\((\d+)초 초과\)", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["timeout"] = int(match.group(1))
|
||||||
|
return ErrorState.CONTAINER_TIMEOUT, "미디어 처리 시간 초과", extra_info
|
||||||
|
|
||||||
|
# 컨테이너 상태 에러
|
||||||
|
if "[ContainerStatus]" in error_str:
|
||||||
|
match = re.search(r"처리 실패: (\w+)", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["status"] = match.group(1)
|
||||||
|
return ErrorState.CONTAINER_ERROR, "미디어 컨테이너 처리 실패", extra_info
|
||||||
|
|
||||||
|
# Instagram API 에러
|
||||||
|
if "[InstagramAPI]" in error_str:
|
||||||
|
match = re.search(r"code=(\d+)", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["code"] = int(match.group(1))
|
||||||
|
return ErrorState.API_ERROR, "Instagram API 오류", extra_info
|
||||||
|
|
||||||
|
return ErrorState.UNKNOWN, str(e), extra_info
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Instagram Client
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramClient:
|
||||||
|
"""
|
||||||
|
Instagram Graph API 비동기 클라이언트 (비디오 업로드 전용)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with InstagramClient(access_token="USER_TOKEN") as client:
|
||||||
|
media = await client.publish_video(
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
caption="My video!"
|
||||||
|
)
|
||||||
|
print(f"게시됨: {media.permalink}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://graph.instagram.com/v21.0"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
*,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
max_retries: int = 3,
|
||||||
|
container_timeout: float = 300.0,
|
||||||
|
container_poll_interval: float = 5.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
클라이언트 초기화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Instagram 액세스 토큰 (필수)
|
||||||
|
base_url: API 기본 URL (기본값: https://graph.instagram.com/v21.0)
|
||||||
|
timeout: HTTP 요청 타임아웃 (초)
|
||||||
|
max_retries: 최대 재시도 횟수
|
||||||
|
container_timeout: 컨테이너 처리 대기 타임아웃 (초)
|
||||||
|
container_poll_interval: 컨테이너 상태 확인 간격 (초)
|
||||||
|
"""
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("access_token은 필수입니다.")
|
||||||
|
|
||||||
|
self.access_token = access_token
|
||||||
|
self.base_url = base_url or self.DEFAULT_BASE_URL
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.container_timeout = container_timeout
|
||||||
|
self.container_poll_interval = container_poll_interval
|
||||||
|
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
self._account_id: Optional[str] = None
|
||||||
|
self._account_id_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "InstagramClient":
|
||||||
|
"""비동기 컨텍스트 매니저 진입"""
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(self.timeout),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
logger.debug("[InstagramClient] HTTP 클라이언트 초기화 완료")
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""비동기 컨텍스트 매니저 종료"""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
logger.debug("[InstagramClient] HTTP 클라이언트 종료")
|
||||||
|
|
||||||
|
def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""HTTP 클라이언트 반환"""
|
||||||
|
if self._client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"InstagramClient는 비동기 컨텍스트 매니저로 사용해야 합니다. "
|
||||||
|
"예: async with InstagramClient(access_token=...) as client:"
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _build_url(self, endpoint: str) -> str:
|
||||||
|
"""API URL 생성"""
|
||||||
|
return f"{self.base_url}/{endpoint}"
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[dict[str, Any]] = None,
|
||||||
|
data: Optional[dict[str, Any]] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
공통 HTTP 요청 처리
|
||||||
|
|
||||||
|
- Rate Limit 시 지수 백오프 재시도
|
||||||
|
- 에러 응답 시 InstagramAPIError 발생
|
||||||
|
"""
|
||||||
|
client = self._get_client()
|
||||||
|
url = self._build_url(endpoint)
|
||||||
|
params = params or {}
|
||||||
|
params["access_token"] = self.access_token
|
||||||
|
|
||||||
|
retry_base_delay = 1.0
|
||||||
|
last_exception: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
try:
|
||||||
|
logger.debug(
|
||||||
|
f"[API] {method} {endpoint} (attempt {attempt + 1}/{self.max_retries + 1})"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rate Limit 체크 (429)
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = int(response.headers.get("Retry-After", 60))
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
wait_time = max(retry_base_delay * (2**attempt), retry_after)
|
||||||
|
logger.warning(f"Rate limit 초과. {wait_time}초 후 재시도...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
raise Exception(
|
||||||
|
f"[RateLimit] Rate limit 초과 (최대 재시도 횟수 도달) | retry_after={retry_after}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 서버 에러 재시도 (5xx)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
wait_time = retry_base_delay * (2**attempt)
|
||||||
|
logger.warning(f"서버 에러 {response.status_code}. {wait_time}초 후 재시도...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# JSON 파싱
|
||||||
|
response_data = response.json()
|
||||||
|
|
||||||
|
# API 에러 체크 (Instagram API는 200 응답에도 error 포함 가능)
|
||||||
|
if "error" in response_data:
|
||||||
|
error_response = ErrorResponse.model_validate(response_data)
|
||||||
|
err = error_response.error
|
||||||
|
logger.error(f"[API Error] code={err.code}, message={err.message}")
|
||||||
|
error_msg = f"[InstagramAPI] {err.message} | code={err.code}"
|
||||||
|
if err.error_subcode:
|
||||||
|
error_msg += f" | subcode={err.error_subcode}"
|
||||||
|
if err.fbtrace_id:
|
||||||
|
error_msg += f" | fbtrace_id={err.fbtrace_id}"
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
last_exception = e
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
wait_time = retry_base_delay * (2**attempt)
|
||||||
|
logger.warning(f"HTTP 에러: {e}. {wait_time}초 후 재시도...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise last_exception or Exception("[InstagramAPI] 최대 재시도 횟수 초과")
|
||||||
|
|
||||||
|
async def _wait_for_container(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
) -> MediaContainer:
|
||||||
|
"""컨테이너 상태가 FINISHED가 될 때까지 대기"""
|
||||||
|
timeout = timeout or self.container_timeout
|
||||||
|
start_time = time.monotonic()
|
||||||
|
|
||||||
|
logger.debug(f"[Container] 대기 시작: {container_id}, timeout={timeout}s")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
elapsed = time.monotonic() - start_time
|
||||||
|
if elapsed >= timeout:
|
||||||
|
raise Exception(
|
||||||
|
f"[ContainerTimeout] 컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self._request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=container_id,
|
||||||
|
params={"fields": "status_code,status"},
|
||||||
|
)
|
||||||
|
|
||||||
|
container = MediaContainer.model_validate(response)
|
||||||
|
logger.debug(f"[Container] status={container.status_code}, elapsed={elapsed:.1f}s")
|
||||||
|
|
||||||
|
if container.is_finished:
|
||||||
|
logger.info(f"[Container] 완료: {container_id}")
|
||||||
|
return container
|
||||||
|
|
||||||
|
if container.is_error:
|
||||||
|
raise Exception(f"[ContainerStatus] 컨테이너 처리 실패: {container.status}")
|
||||||
|
|
||||||
|
await asyncio.sleep(self.container_poll_interval)
|
||||||
|
|
||||||
|
async def get_account_id(self) -> str:
|
||||||
|
"""계정 ID 조회 (접속 테스트용)"""
|
||||||
|
if self._account_id:
|
||||||
|
return self._account_id
|
||||||
|
|
||||||
|
async with self._account_id_lock:
|
||||||
|
if self._account_id:
|
||||||
|
return self._account_id
|
||||||
|
|
||||||
|
response = await self._request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="me",
|
||||||
|
params={"fields": "id"},
|
||||||
|
)
|
||||||
|
account_id: str = response["id"]
|
||||||
|
self._account_id = account_id
|
||||||
|
logger.debug(f"[Account] ID 조회 완료: {account_id}")
|
||||||
|
return account_id
|
||||||
|
|
||||||
|
async def get_media(self, media_id: str) -> Media:
|
||||||
|
"""
|
||||||
|
미디어 상세 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_id: 미디어 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Media: 미디어 상세 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"[get_media] media_id={media_id}")
|
||||||
|
|
||||||
|
response = await self._request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=media_id,
|
||||||
|
params={
|
||||||
|
"fields": "id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Media.model_validate(response)
|
||||||
|
logger.info(f"[get_media] 완료: type={result.media_type}, permalink={result.permalink}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def publish_video(
|
||||||
|
self,
|
||||||
|
video_url: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
share_to_feed: bool = True,
|
||||||
|
) -> Media:
|
||||||
|
"""
|
||||||
|
비디오/릴스 게시
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_url: 공개 접근 가능한 비디오 URL (MP4 권장)
|
||||||
|
caption: 게시물 캡션
|
||||||
|
share_to_feed: 피드에 공유 여부
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Media: 게시된 미디어 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"[publish_video] 시작: {video_url[:50]}...")
|
||||||
|
account_id = await self.get_account_id()
|
||||||
|
|
||||||
|
# Step 1: Container 생성
|
||||||
|
container_params: dict[str, Any] = {
|
||||||
|
"media_type": "REELS",
|
||||||
|
"video_url": video_url,
|
||||||
|
"share_to_feed": str(share_to_feed).lower(),
|
||||||
|
}
|
||||||
|
if caption:
|
||||||
|
container_params["caption"] = caption
|
||||||
|
|
||||||
|
container_response = await self._request(
|
||||||
|
method="POST",
|
||||||
|
endpoint=f"{account_id}/media",
|
||||||
|
params=container_params,
|
||||||
|
)
|
||||||
|
container_id = container_response["id"]
|
||||||
|
logger.debug(f"[publish_video] Container 생성: {container_id}")
|
||||||
|
|
||||||
|
# Step 2: Container 상태 대기 (비디오는 더 오래 걸림)
|
||||||
|
await self._wait_for_container(container_id, timeout=self.container_timeout * 2)
|
||||||
|
|
||||||
|
# Step 3: 게시
|
||||||
|
publish_response = await self._request(
|
||||||
|
method="POST",
|
||||||
|
endpoint=f"{account_id}/media_publish",
|
||||||
|
params={"creation_id": container_id},
|
||||||
|
)
|
||||||
|
media_id = publish_response["id"]
|
||||||
|
|
||||||
|
result = await self.get_media(media_id)
|
||||||
|
logger.info(f"[publish_video] 완료: {result.permalink}")
|
||||||
|
return result
|
||||||
|
|
@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.utils.timezone import today_str
|
||||||
from config import log_settings
|
from config import log_settings
|
||||||
|
|
||||||
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
|
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
|
||||||
|
|
@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
|
||||||
global _shared_file_handler
|
global _shared_file_handler
|
||||||
|
|
||||||
if _shared_file_handler is None:
|
if _shared_file_handler is None:
|
||||||
today = datetime.today().strftime("%Y-%m-%d")
|
today = today_str()
|
||||||
log_file = LOG_DIR / f"{today}_app.log"
|
log_file = LOG_DIR / f"{today}_app.log"
|
||||||
|
|
||||||
_shared_file_handler = RotatingFileHandler(
|
_shared_file_handler = RotatingFileHandler(
|
||||||
|
|
@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
|
||||||
global _shared_error_handler
|
global _shared_error_handler
|
||||||
|
|
||||||
if _shared_error_handler is None:
|
if _shared_error_handler is None:
|
||||||
today = datetime.today().strftime("%Y-%m-%d")
|
today = today_str()
|
||||||
log_file = LOG_DIR / f"{today}_error.log"
|
log_file = LOG_DIR / f"{today}_error.log"
|
||||||
|
|
||||||
_shared_error_handler = RotatingFileHandler(
|
_shared_error_handler = RotatingFileHandler(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
import time
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("pwscraper")
|
||||||
|
|
||||||
class NvMapPwScraper():
|
class NvMapPwScraper():
|
||||||
# cls vars
|
# cls vars
|
||||||
|
|
@ -10,7 +15,8 @@ class NvMapPwScraper():
|
||||||
_context = None
|
_context = None
|
||||||
_win_width = 1280
|
_win_width = 1280
|
||||||
_win_height = 720
|
_win_height = 720
|
||||||
_max_retry = 30 # place id timeout threshold seconds
|
_max_retry = 3
|
||||||
|
_timeout = 60 # place id timeout threshold seconds
|
||||||
|
|
||||||
# instance var
|
# instance var
|
||||||
page = None
|
page = None
|
||||||
|
|
@ -90,22 +96,54 @@ patchedGetter.toString();''')
|
||||||
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
await page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||||
|
|
||||||
async def get_place_id_url(self, selected):
|
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>", "")
|
title = selected['title'].replace("<b>", "").replace("</b>", "")
|
||||||
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
|
||||||
encoded_query = parse.quote(f"{address} {title}")
|
encoded_query = parse.quote(f"{address} {title}")
|
||||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||||
|
|
||||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
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:
|
if "/place/" in self.page.url:
|
||||||
return 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&")
|
url = self.page.url.replace("?","?isCorrectAnswer=true&")
|
||||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
try:
|
||||||
|
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
|
||||||
|
except:
|
||||||
|
if "/place/" in self.page.url:
|
||||||
|
return self.page.url
|
||||||
|
logger.error(f"[ERROR] Can't Finish networkidle")
|
||||||
|
|
||||||
|
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:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
logger.error("[ERROR] Not found url for {selected}")
|
||||||
|
|
||||||
|
return None # 404
|
||||||
|
|
||||||
|
|
||||||
# if (count == self._max_retry / 2):
|
# if (count == self._max_retry / 2):
|
||||||
# raise Exception("Failed to identify place id. loading timeout")
|
# 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"
|
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||||
REQUEST_TIMEOUT = 120 # 초
|
REQUEST_TIMEOUT = 120 # 초
|
||||||
|
data_source_identifier = "nv"
|
||||||
OVERVIEW_QUERY: str = """
|
OVERVIEW_QUERY: str = """
|
||||||
query getAccommodation($id: String!, $deviceType: String) {
|
query getAccommodation($id: String!, $deviceType: String) {
|
||||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
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)
|
data = await self._call_get_accommodation(place_id)
|
||||||
self.rawdata = data
|
self.rawdata = data
|
||||||
fac_data = await self._get_facility_string(place_id)
|
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.rawdata["facilities"] = fac_data
|
||||||
self.image_link_list = [
|
self.image_link_list = [
|
||||||
nv_image["origin"]
|
nv_image["origin"]
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,14 @@ lyric_prompt = Prompt(
|
||||||
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
|
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():
|
def reload_all_prompt():
|
||||||
marketing_prompt._reload_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 .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):
|
class SellingPoint(BaseModel):
|
||||||
category: str = Field(..., description="셀링포인트 카테고리")
|
english_category: str = Field(..., description="셀링포인트 카테고리(영문)")
|
||||||
|
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")
|
||||||
description: 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):
|
class MarketingPromptOutput(BaseModel):
|
||||||
brand_identity: BrandIdentity = Field(..., description="브랜드 아이덴티티")
|
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.
|
specializing in Korean pension / accommodation businesses.
|
||||||
You create lyrics strictly based on Brand & Marketing Intelligence analysis
|
You create lyrics strictly based on Brand & Marketing Intelligence analysis
|
||||||
and optimized for viral short-form video content.
|
and optimized for viral short-form video content.
|
||||||
|
Marketing Intelligence Report is background reference.
|
||||||
|
|
||||||
[INPUT]
|
[INPUT]
|
||||||
Business Name: {customer_name}
|
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
|
# Output Rules
|
||||||
1. **Language:** All descriptive content must be written in **Korean (한국어)**.
|
1. **Language:** All descriptive content must be written in **Korean (한국어)**.
|
||||||
2. **Terminology:** Use professional marketing terminology suitable for the hospitality and stay industry.
|
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:
|
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`.
|
* `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.
|
* **`decision_trigger`**: Identify the specific "Hook" or facility that leads this persona to finalize a booking.
|
||||||
|
|
||||||
### 4. selling_points
|
### 4. selling_points
|
||||||
Generate exactly 7 selling points:
|
Generate 5-8 selling points:
|
||||||
* **`category`**: Strictly use one keyword from the English allowed list provided in the Output Rules.
|
* **`english_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).
|
* **`korean category`**: Strictly use one keyword from the Korean allowed list provided in the Output Rules . It must be matched with english category.
|
||||||
* **`score`**: An integer (70-99) representing the strength of this feature based on the brand's potential.
|
* **`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
|
### 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:
|
if data.get("code") != 200:
|
||||||
error_msg = data.get("msg", "Unknown error")
|
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")
|
response_data = data.get("data")
|
||||||
if response_data is None:
|
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)
|
||||||
|
|
@ -32,6 +32,7 @@ URL 경로 형식:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -129,6 +130,33 @@ class AzureBlobUploader:
|
||||||
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
||||||
return self._last_public_url
|
return self._last_public_url
|
||||||
|
|
||||||
|
def _sanitize_filename(self, file_name: str) -> str:
|
||||||
|
"""파일명에서 공백/특수문자 제거, 한글/영문/숫자만 허용
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_name: 원본 파일명
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 정리된 파일명 (한글, 영문, 숫자만 포함)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> self._sanitize_filename("my file (1).mp4")
|
||||||
|
'myfile1.mp4'
|
||||||
|
>>> self._sanitize_filename("테스트 파일!@#.png")
|
||||||
|
'테스트파일.png'
|
||||||
|
"""
|
||||||
|
stem = Path(file_name).stem
|
||||||
|
suffix = Path(file_name).suffix
|
||||||
|
|
||||||
|
# 한글(가-힣), 영문(a-zA-Z), 숫자(0-9)만 남기고 제거
|
||||||
|
sanitized = re.sub(r'[^가-힣a-zA-Z0-9]', '', stem)
|
||||||
|
|
||||||
|
# 빈 문자열이면 기본값 사용
|
||||||
|
if not sanitized:
|
||||||
|
sanitized = "file"
|
||||||
|
|
||||||
|
return f"{sanitized}{suffix}"
|
||||||
|
|
||||||
def _build_upload_url(self, category: str, file_name: str) -> str:
|
def _build_upload_url(self, category: str, file_name: str) -> str:
|
||||||
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
||||||
# SAS 토큰 앞뒤의 ?, ', " 제거
|
# SAS 토큰 앞뒤의 ?, ', " 제거
|
||||||
|
|
@ -238,8 +266,8 @@ class AzureBlobUploader:
|
||||||
Returns:
|
Returns:
|
||||||
bool: 업로드 성공 여부
|
bool: 업로드 성공 여부
|
||||||
"""
|
"""
|
||||||
# 파일 경로에서 파일명 추출
|
# 파일 경로에서 파일명 추출 후 정리 (공백/특수문자 제거)
|
||||||
file_name = Path(file_path).name
|
file_name = self._sanitize_filename(Path(file_path).name)
|
||||||
|
|
||||||
upload_url = self._build_upload_url(category, file_name)
|
upload_url = self._build_upload_url(category, file_name)
|
||||||
self._last_public_url = self._build_public_url(category, file_name)
|
self._last_public_url = self._build_public_url(category, file_name)
|
||||||
|
|
@ -301,7 +329,8 @@ class AzureBlobUploader:
|
||||||
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
||||||
print(uploader.public_url)
|
print(uploader.public_url)
|
||||||
"""
|
"""
|
||||||
# 확장자가 없으면 .mp3 추가
|
# 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp3 추가
|
||||||
|
file_name = self._sanitize_filename(file_name)
|
||||||
if not Path(file_name).suffix:
|
if not Path(file_name).suffix:
|
||||||
file_name = f"{file_name}.mp3"
|
file_name = f"{file_name}.mp3"
|
||||||
|
|
||||||
|
|
@ -363,7 +392,8 @@ class AzureBlobUploader:
|
||||||
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
||||||
print(uploader.public_url)
|
print(uploader.public_url)
|
||||||
"""
|
"""
|
||||||
# 확장자가 없으면 .mp4 추가
|
# 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp4 추가
|
||||||
|
file_name = self._sanitize_filename(file_name)
|
||||||
if not Path(file_name).suffix:
|
if not Path(file_name).suffix:
|
||||||
file_name = f"{file_name}.mp4"
|
file_name = f"{file_name}.mp4"
|
||||||
|
|
||||||
|
|
@ -430,9 +460,13 @@ class AzureBlobUploader:
|
||||||
success = await uploader.upload_image_bytes(content, "my_image.png")
|
success = await uploader.upload_image_bytes(content, "my_image.png")
|
||||||
print(uploader.public_url)
|
print(uploader.public_url)
|
||||||
"""
|
"""
|
||||||
|
# Content-Type 결정을 위해 먼저 확장자 추출
|
||||||
extension = Path(file_name).suffix.lower()
|
extension = Path(file_name).suffix.lower()
|
||||||
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
||||||
|
|
||||||
|
# 파일명 정리 (공백/특수문자 제거)
|
||||||
|
file_name = self._sanitize_filename(file_name)
|
||||||
|
|
||||||
upload_url = self._build_upload_url("image", file_name)
|
upload_url = self._build_upload_url("image", file_name)
|
||||||
self._last_public_url = self._build_public_url("image", file_name)
|
self._last_public_url = self._build_public_url("image", file_name)
|
||||||
log_prefix = "upload_image_bytes"
|
log_prefix = "upload_image_bytes"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ Video API Router
|
||||||
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
||||||
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
||||||
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
||||||
- GET /video/list: 완료된 영상 목록 조회 (페이지네이션)
|
|
||||||
|
|
||||||
사용 예시:
|
사용 예시:
|
||||||
from app.video.api.routers.v1.video import router
|
from app.video.api.routers.v1.video import router
|
||||||
|
|
@ -18,11 +17,10 @@ import json
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
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.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.home.models import Image, Project
|
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.song.models import Song, SongTimestamp
|
||||||
from app.utils.creatomate import CreatomateService
|
from app.utils.creatomate import CreatomateService
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.video.schemas.video_schema import (
|
from app.video.schemas.video_schema import (
|
||||||
DownloadVideoResponse,
|
DownloadVideoResponse,
|
||||||
GenerateVideoResponse,
|
GenerateVideoResponse,
|
||||||
PollingVideoResponse,
|
PollingVideoResponse,
|
||||||
VideoListItem,
|
|
||||||
VideoRenderData,
|
VideoRenderData,
|
||||||
)
|
)
|
||||||
from app.video.worker.video_task import download_and_upload_video_to_blob
|
from app.video.worker.video_task import download_and_upload_video_to_blob
|
||||||
|
|
||||||
|
from config import creatomate_settings
|
||||||
|
|
||||||
logger = get_logger("video")
|
logger = get_logger("video")
|
||||||
|
|
||||||
router = APIRouter(prefix="/video", tags=["Video"])
|
router = APIRouter(prefix="/video", tags=["Video"])
|
||||||
|
|
@ -199,6 +197,7 @@ async def generate_video(
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
project_id = project.id
|
project_id = project.id
|
||||||
|
store_address = project.detail_region_info
|
||||||
|
|
||||||
# ===== 결과 처리: Lyric =====
|
# ===== 결과 처리: Lyric =====
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
|
|
@ -209,6 +208,7 @@ async def generate_video(
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
||||||
)
|
)
|
||||||
lyric_id = lyric.id
|
lyric_id = lyric.id
|
||||||
|
lyric_language = lyric.language
|
||||||
|
|
||||||
# ===== 결과 처리: Song =====
|
# ===== 결과 처리: Song =====
|
||||||
song = song_result.scalar_one_or_none()
|
song = song_result.scalar_one_or_none()
|
||||||
|
|
@ -311,6 +311,7 @@ async def generate_video(
|
||||||
image_url_list=image_urls,
|
image_url_list=image_urls,
|
||||||
lyric=lyrics,
|
lyric=lyrics,
|
||||||
music_url=music_url,
|
music_url=music_url,
|
||||||
|
address=store_address
|
||||||
)
|
)
|
||||||
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
|
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}"
|
f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 이런거 추가해야하는데 AI가 자꾸 번호 달면 제가 번호를 다 밀어야 하나요?
|
|
||||||
|
|
||||||
song_timestamp_result = await session.execute(
|
song_timestamp_result = await session.execute(
|
||||||
select(SongTimestamp).where(
|
select(SongTimestamp).where(
|
||||||
SongTimestamp.suno_audio_id == song.suno_audio_id
|
SongTimestamp.suno_audio_id == song.suno_audio_id
|
||||||
|
|
@ -348,6 +347,18 @@ async def generate_video(
|
||||||
f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}"
|
f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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()
|
text_template = creatomate_service.get_text_template()
|
||||||
for idx, aligned in enumerate(song_timestamp_list):
|
for idx, aligned in enumerate(song_timestamp_list):
|
||||||
caption = creatomate_service.lining_lyric(
|
caption = creatomate_service.lining_lyric(
|
||||||
|
|
@ -356,9 +367,10 @@ async def generate_video(
|
||||||
aligned.lyric_line,
|
aligned.lyric_line,
|
||||||
aligned.start_time,
|
aligned.start_time,
|
||||||
aligned.end_time,
|
aligned.end_time,
|
||||||
|
lyric_font
|
||||||
)
|
)
|
||||||
final_template["source"]["elements"].append(caption)
|
final_template["source"]["elements"].append(caption)
|
||||||
|
# END - LYRIC AUTO 결정부
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
||||||
# )
|
# )
|
||||||
|
|
@ -551,25 +563,16 @@ async def get_video_status(
|
||||||
|
|
||||||
if video and video.status != "completed":
|
if video and video.status != "completed":
|
||||||
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
|
# 이미 완료된 경우 백그라운드 작업 중복 실행 방지
|
||||||
# task_id로 Project 조회하여 store_name 가져오기
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project).where(Project.id == video.project_id)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
store_name = project.store_name if project else "video"
|
|
||||||
|
|
||||||
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}, creatomate_render_id: {creatomate_render_id}"
|
f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, creatomate_render_id: {creatomate_render_id}"
|
||||||
)
|
)
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
download_and_upload_video_to_blob,
|
download_and_upload_video_to_blob,
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
store_name=store_name,
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
creatomate_render_id=creatomate_render_id,
|
creatomate_render_id=creatomate_render_id,
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
)
|
)
|
||||||
elif video and video.status == "completed":
|
elif video and video.status == "completed":
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -731,126 +734,3 @@ async def download_video(
|
||||||
message="영상 다운로드 조회에 실패했습니다.",
|
message="영상 다운로드 조회에 실패했습니다.",
|
||||||
error_message=str(e),
|
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(
|
|
||||||
video_id=video.id,
|
|
||||||
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)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,29 +105,25 @@ async def _download_video(url: str, task_id: str) -> bytes:
|
||||||
async def download_and_upload_video_to_blob(
|
async def download_and_upload_video_to_blob(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
video_url: str,
|
video_url: str,
|
||||||
store_name: str,
|
creatomate_render_id: str,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
creatomate_render_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||||
|
|
||||||
|
파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: 프로젝트 task_id
|
task_id: 프로젝트 task_id
|
||||||
video_url: 다운로드할 영상 URL
|
video_url: 다운로드할 영상 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
creatomate_render_id: Creatomate API 렌더 ID (파일명 및 Video 식별용)
|
||||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||||
creatomate_render_id: Creatomate 렌더 ID (특정 Video 식별용)
|
|
||||||
"""
|
"""
|
||||||
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
|
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
|
||||||
temp_file_path: Path | None = None
|
temp_file_path: Path | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
# creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||||
safe_store_name = "".join(
|
file_name = f"{creatomate_render_id}.mp4"
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "video"
|
|
||||||
file_name = f"{safe_store_name}.mp4"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
# 임시 저장 경로 생성
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
|
|
@ -193,18 +189,18 @@ async def download_and_upload_video_to_blob(
|
||||||
async def download_and_upload_video_by_creatomate_render_id(
|
async def download_and_upload_video_by_creatomate_render_id(
|
||||||
creatomate_render_id: str,
|
creatomate_render_id: str,
|
||||||
video_url: str,
|
video_url: str,
|
||||||
store_name: str,
|
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
|
||||||
|
|
||||||
|
파일명은 creatomate_render_id를 사용하여 고유성을 보장합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
creatomate_render_id: Creatomate API 렌더 ID
|
creatomate_render_id: Creatomate API 렌더 ID (파일명으로도 사용)
|
||||||
video_url: 다운로드할 영상 URL
|
video_url: 다운로드할 영상 URL
|
||||||
store_name: 저장할 파일명에 사용할 업체명
|
|
||||||
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
|
||||||
"""
|
"""
|
||||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}")
|
||||||
temp_file_path: Path | None = None
|
temp_file_path: Path | None = None
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
|
|
||||||
|
|
@ -226,12 +222,8 @@ async def download_and_upload_video_by_creatomate_render_id(
|
||||||
task_id = video.task_id
|
task_id = video.task_id
|
||||||
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
|
||||||
|
|
||||||
# 파일명에 사용할 수 없는 문자 제거
|
# creatomate_render_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
|
||||||
safe_store_name = "".join(
|
file_name = f"{creatomate_render_id}.mp4"
|
||||||
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
||||||
).strip()
|
|
||||||
safe_store_name = safe_store_name or "video"
|
|
||||||
file_name = f"{safe_store_name}.mp4"
|
|
||||||
|
|
||||||
# 임시 저장 경로 생성
|
# 임시 저장 경로 생성
|
||||||
temp_dir = Path("media") / "temp" / task_id
|
temp_dir = Path("media") / "temp" / task_id
|
||||||
|
|
|
||||||
21
config.py
21
config.py
|
|
@ -1,10 +1,19 @@
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent
|
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 = PROJECT_DIR / "media"
|
||||||
MEDIA_ROOT.mkdir(exist_ok=True)
|
MEDIA_ROOT.mkdir(exist_ok=True)
|
||||||
|
|
@ -23,6 +32,10 @@ class ProjectSettings(BaseSettings):
|
||||||
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
|
||||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
ADMIN_BASE_URL: str = Field(default="/admin")
|
||||||
DEBUG: bool = Field(default=True)
|
DEBUG: bool = Field(default=True)
|
||||||
|
TIMEZONE: str = Field(
|
||||||
|
default="Asia/Seoul",
|
||||||
|
description="프로젝트 전역 타임존 (예: Asia/Seoul, UTC, America/New_York)",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
@ -155,6 +168,10 @@ class CreatomateSettings(BaseSettings):
|
||||||
default=30.0,
|
default=30.0,
|
||||||
description="가로형 템플릿 기본 duration (초)",
|
description="가로형 템플릿 기본 duration (초)",
|
||||||
)
|
)
|
||||||
|
DEBUG_AUTO_LYRIC: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Creatomate 자동 가사 생성 기능 사용 여부",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
@ -167,6 +184,8 @@ class PromptSettings(BaseSettings):
|
||||||
LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt")
|
LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt")
|
||||||
LYRIC_PROMPT_MODEL : str = Field(default="gpt-5-mini")
|
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
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -581,9 +600,9 @@ class SocialUploadSettings(BaseSettings):
|
||||||
|
|
||||||
|
|
||||||
prj_settings = ProjectSettings()
|
prj_settings = ProjectSettings()
|
||||||
|
cors_settings = CORSSettings()
|
||||||
apikey_settings = APIKeySettings()
|
apikey_settings = APIKeySettings()
|
||||||
db_settings = DatabaseSettings()
|
db_settings = DatabaseSettings()
|
||||||
cors_settings = CORSSettings()
|
|
||||||
crawler_settings = CrawlerSettings()
|
crawler_settings = CrawlerSettings()
|
||||||
naver_api_settings = NaverAPISettings()
|
naver_api_settings = NaverAPISettings()
|
||||||
azure_blob_settings = AzureBlobSettings()
|
azure_blob_settings = AzureBlobSettings()
|
||||||
|
|
|
||||||
|
|
@ -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,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,238 @@
|
||||||
|
# Instagram POC 개발 계획서
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
Instagram Graph API를 사용하여 이미지, 영상, 컨텐츠를 업로드하고 결과를 확인하는 POC 모듈 개발
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
- 단일 파일, 단일 클래스로 Instagram API 기능 구현
|
||||||
|
- 여러 사용자가 각자의 계정으로 컨텐츠 업로드 가능 (멀티테넌트)
|
||||||
|
- API 예외처리 및 에러 핸들링
|
||||||
|
- 테스트 파일 및 사용 매뉴얼 제공
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
|
||||||
|
1. **기존 코드**: `poc/instagram1/` 폴더
|
||||||
|
2. **공식 문서**: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing
|
||||||
|
|
||||||
|
## 기존 코드(instagram1) 분석 결과
|
||||||
|
|
||||||
|
### 발견된 문제점 (모두 수정 완료 ✅)
|
||||||
|
|
||||||
|
| 심각도 | 문제 | 설명 | 상태 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| 🔴 Critical | 잘못된 import 경로 | `poc.instagram` → `poc.instagram1`로 수정 | ✅ 완료 |
|
||||||
|
| 🔴 Critical | Timezone 혼합 | `datetime.now()` → `datetime.now(timezone.utc)`로 수정 | ✅ 완료 |
|
||||||
|
| 🟡 Warning | 파일 중간 import | `config.py`에서 `lru_cache` import를 파일 상단으로 이동 | ✅ 완료 |
|
||||||
|
| 🟡 Warning | Deprecated alias 사용 | `PermissionError` → `InstagramPermissionError`로 변경 | ✅ 완료 |
|
||||||
|
| 🟡 Warning | docstring 경로 오류 | `__init__.py` 예제 경로 수정 | ✅ 완료 |
|
||||||
|
|
||||||
|
### 수정된 파일
|
||||||
|
|
||||||
|
- `poc/instagram1/config.py` - import 위치 수정
|
||||||
|
- `poc/instagram1/__init__.py` - docstring 경로 수정
|
||||||
|
- `poc/instagram1/examples/auth_example.py` - timezone 및 import 경로 수정
|
||||||
|
- `poc/instagram1/examples/account_example.py` - import 경로 수정
|
||||||
|
- `poc/instagram1/examples/comments_example.py` - import 경로 수정
|
||||||
|
- `poc/instagram1/examples/insights_example.py` - import 경로 및 deprecated alias 수정
|
||||||
|
- `poc/instagram1/examples/media_example.py` - import 경로 수정
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
|
||||||
|
```
|
||||||
|
poc/instagram/
|
||||||
|
├── client.py # InstagramClient 클래스 + 예외 클래스
|
||||||
|
├── main.py # 테스트 실행 파일
|
||||||
|
└── poc.md # 사용 매뉴얼
|
||||||
|
```
|
||||||
|
|
||||||
|
## 필수 기능
|
||||||
|
|
||||||
|
1. **이미지 업로드** - 단일 이미지 게시
|
||||||
|
2. **영상(릴스) 업로드** - 비디오 게시
|
||||||
|
3. **캐러셀 업로드** - 멀티 이미지 게시 (2-10개)
|
||||||
|
4. **미디어 조회** - 업로드된 게시물 확인
|
||||||
|
5. **예외처리** - API 에러 코드별 처리
|
||||||
|
|
||||||
|
## 설계 요구사항
|
||||||
|
|
||||||
|
### 클래스 구조
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InstagramClient:
|
||||||
|
"""
|
||||||
|
Instagram Graph API 클라이언트
|
||||||
|
|
||||||
|
- 인스턴스 생성 시 access_token 전달 (멀티테넌트 지원)
|
||||||
|
- 비동기 컨텍스트 매니저 패턴
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, access_token: str): ...
|
||||||
|
|
||||||
|
async def publish_image(self, image_url: str, caption: str) -> Media: ...
|
||||||
|
async def publish_video(self, video_url: str, caption: str) -> Media: ...
|
||||||
|
async def publish_carousel(self, media_urls: list[str], caption: str) -> Media: ...
|
||||||
|
async def get_media(self, media_id: str) -> Media: ...
|
||||||
|
async def get_media_list(self, limit: int) -> list[Media]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예외 클래스
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InstagramAPIError(Exception): ... # 기본 예외
|
||||||
|
class AuthenticationError(InstagramAPIError): ... # 인증 오류
|
||||||
|
class RateLimitError(InstagramAPIError): ... # Rate Limit 초과
|
||||||
|
class MediaPublishError(InstagramAPIError): ... # 게시 실패
|
||||||
|
class InvalidRequestError(InstagramAPIError): ... # 잘못된 요청
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 에이전트 워크플로우
|
||||||
|
|
||||||
|
### 1단계: 설계 에이전트 (`/design`)
|
||||||
|
|
||||||
|
```
|
||||||
|
/design
|
||||||
|
|
||||||
|
## 요청 개요
|
||||||
|
Instagram Graph API를 사용하여 이미지, 영상, 컨텐츠를 업로드하고 결과를 확인하는 POC 모듈 설계
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
1. Instagram Graph API 공식 문서: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing
|
||||||
|
2. 기존 코드: poc/instagram1/ 폴더 내용
|
||||||
|
|
||||||
|
## 요구사항
|
||||||
|
1. poc/instagram/ 폴더에 단일 파일, 단일 클래스로 구현
|
||||||
|
2. 여러 사용자가 각자의 계정으로 컨텐츠 업로드 가능하도록 설계 (멀티테넌트)
|
||||||
|
3. 필수 기능:
|
||||||
|
- 이미지 업로드
|
||||||
|
- 영상(릴스) 업로드
|
||||||
|
- 캐러셀(멀티 이미지) 업로드
|
||||||
|
- 업로드된 미디어 조회
|
||||||
|
4. Instagram API 에러 코드별 예외처리
|
||||||
|
5. main.py 테스트 파일 포함
|
||||||
|
6. poc.md 사용 매뉴얼 문서 포함
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
- 클래스 구조 및 메서드 시그니처
|
||||||
|
- 예외 클래스 설계
|
||||||
|
- 파일 구조
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2단계: 개발 에이전트 (`/develop`)
|
||||||
|
|
||||||
|
```
|
||||||
|
/develop
|
||||||
|
|
||||||
|
## 작업 내용
|
||||||
|
1단계 설계를 기반으로 Instagram POC 모듈 구현
|
||||||
|
|
||||||
|
## 구현 대상
|
||||||
|
1. poc/instagram/client.py
|
||||||
|
- InstagramClient 클래스 (단일 클래스로 모든 기능 포함)
|
||||||
|
- 예외 클래스들 (같은 파일 내 정의)
|
||||||
|
- 기능별 주석 필수
|
||||||
|
|
||||||
|
2. poc/instagram/main.py
|
||||||
|
- 테스트 코드 (import하여 각 기능 테스트)
|
||||||
|
- 환경변수 기반 토큰 설정
|
||||||
|
|
||||||
|
3. poc/instagram/poc.md
|
||||||
|
- 동작 원리 설명
|
||||||
|
- 환경 설정 방법
|
||||||
|
- 사용 예제 코드
|
||||||
|
- API 제한사항
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
- poc/instagram1/ 폴더의 기존 코드 참고
|
||||||
|
- Instagram Graph API 공식 문서 기반으로 구현
|
||||||
|
- 잘못된 import 경로, timezone 문제 등 기존 코드의 버그 수정 반영
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3단계: 코드리뷰 에이전트 (`/review`)
|
||||||
|
|
||||||
|
```
|
||||||
|
/review
|
||||||
|
|
||||||
|
## 리뷰 대상
|
||||||
|
poc/instagram/ 폴더의 모든 파일
|
||||||
|
|
||||||
|
## 리뷰 항목
|
||||||
|
1. 코드 품질
|
||||||
|
- PEP 8 준수 여부
|
||||||
|
- 타입 힌트 적용
|
||||||
|
- 비동기 패턴 적절성
|
||||||
|
|
||||||
|
2. 기능 완성도
|
||||||
|
- 이미지/영상/캐러셀 업로드 기능
|
||||||
|
- 미디어 조회 기능
|
||||||
|
- 멀티테넌트 지원
|
||||||
|
|
||||||
|
3. 예외처리
|
||||||
|
- API 에러 코드별 처리
|
||||||
|
- Rate Limit 처리
|
||||||
|
- 타임아웃 처리
|
||||||
|
|
||||||
|
4. 문서화
|
||||||
|
- 주석의 적절성
|
||||||
|
- poc.md 완성도
|
||||||
|
|
||||||
|
5. 보안
|
||||||
|
- 토큰 노출 방지
|
||||||
|
- 민감 정보 로깅 마스킹
|
||||||
|
|
||||||
|
## instagram1 대비 개선점 확인
|
||||||
|
- import 경로 오류 수정됨
|
||||||
|
- timezone aware/naive 혼합 문제 수정됨
|
||||||
|
- deprecated alias 제거됨
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 설계 단계
|
||||||
|
/design
|
||||||
|
|
||||||
|
# 2. 개발 단계 (설계 승인 후)
|
||||||
|
/develop
|
||||||
|
|
||||||
|
# 3. 코드 리뷰 단계 (개발 완료 후)
|
||||||
|
/review
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경 설정
|
||||||
|
|
||||||
|
### 필수 환경변수
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export INSTAGRAM_ACCESS_TOKEN="your_access_token"
|
||||||
|
export INSTAGRAM_APP_ID="your_app_id" # 선택
|
||||||
|
export INSTAGRAM_APP_SECRET="your_app_secret" # 선택
|
||||||
|
```
|
||||||
|
|
||||||
|
### 의존성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add httpx pydantic pydantic-settings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 일정
|
||||||
|
|
||||||
|
| 단계 | 작업 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | 설계 (`/design`) | ✅ 완료 |
|
||||||
|
| 2 | 개발 (`/develop`) | ✅ 완료 |
|
||||||
|
| 3 | 코드리뷰 (`/review`) | ⬜ 대기 |
|
||||||
|
| 4 | 테스트 및 검증 | ⬜ 대기 |
|
||||||
|
|
@ -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 토큰 만료 시간 비교 시 타임존 일관성 필수
|
||||||
|
- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일
|
||||||
42
main.py
42
main.py
|
|
@ -14,11 +14,14 @@ from app.user.models import User, RefreshToken # noqa: F401
|
||||||
from app.archive.api.routers.v1.archive import router as archive_router
|
from app.archive.api.routers.v1.archive import router as archive_router
|
||||||
from app.home.api.routers.v1.home import router as home_router
|
from app.home.api.routers.v1.home import router as home_router
|
||||||
from app.user.api.routers.v1.auth import router as auth_router, test_router as auth_test_router
|
from app.user.api.routers.v1.auth import router as auth_router, test_router as auth_test_router
|
||||||
|
from app.user.api.routers.v1.social_account import router as social_account_router
|
||||||
from app.lyric.api.routers.v1.lyric import router as lyric_router
|
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.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.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.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.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 app.utils.cors import CustomCORSMiddleware
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
|
|
||||||
|
|
@ -32,7 +35,7 @@ tags_metadata = [
|
||||||
1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득
|
1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득
|
||||||
2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인
|
2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인
|
||||||
3. 카카오에서 인가 코드(code) 발급
|
3. 카카오에서 인가 코드(code) 발급
|
||||||
4. `POST /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
|
4. `GET /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급 (카카오 리다이렉트)
|
||||||
5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용
|
5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용
|
||||||
|
|
||||||
## 토큰 관리
|
## 토큰 관리
|
||||||
|
|
@ -47,6 +50,21 @@ tags_metadata = [
|
||||||
3. `access_token` 값 입력 (Bearer 접두사 없이 토큰만 입력)
|
3. `access_token` 값 입력 (Bearer 접두사 없이 토큰만 입력)
|
||||||
4. **Authorize** 클릭하여 저장
|
4. **Authorize** 클릭하여 저장
|
||||||
5. 이후 인증이 필요한 API 호출 시 자동으로 토큰이 포함됨
|
5. 이후 인증이 필요한 API 호출 시 자동으로 토큰이 포함됨
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Social Account",
|
||||||
|
"description": """소셜 계정 연동 API - YouTube, Instagram, Facebook, TikTok
|
||||||
|
|
||||||
|
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- `GET /user/social-accounts` - 연동된 소셜 계정 목록 조회
|
||||||
|
- `GET /user/social-accounts/{account_id}` - 소셜 계정 상세 조회
|
||||||
|
- `POST /user/social-accounts` - 소셜 계정 연동
|
||||||
|
- `PATCH /user/social-accounts/{account_id}` - 소셜 계정 정보 수정
|
||||||
|
- `DELETE /user/social-accounts/{account_id}` - 소셜 계정 연동 해제
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
# {
|
# {
|
||||||
|
|
@ -202,6 +220,23 @@ tags_metadata = [
|
||||||
- `processing`: 플랫폼에서 처리 중
|
- `processing`: 플랫폼에서 처리 중
|
||||||
- `completed`: 업로드 완료
|
- `completed`: 업로드 완료
|
||||||
- `failed`: 업로드 실패
|
- `failed`: 업로드 실패
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SNS",
|
||||||
|
"description": """SNS 업로드 API - Instagram Graph API
|
||||||
|
|
||||||
|
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- `POST /sns/instagram/upload/{task_id}` - task_id에 해당하는 비디오를 Instagram에 업로드
|
||||||
|
|
||||||
|
## Instagram 업로드 흐름
|
||||||
|
|
||||||
|
1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조)
|
||||||
|
2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다
|
||||||
|
3. 업로드 성공 시 Instagram media_id와 permalink 반환
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -278,7 +313,7 @@ def custom_openapi():
|
||||||
if method in ["get", "post", "put", "patch", "delete"]:
|
if method in ["get", "post", "put", "patch", "delete"]:
|
||||||
# 공개 엔드포인트가 아닌 경우 인증 필요
|
# 공개 엔드포인트가 아닌 경우 인증 필요
|
||||||
is_public = any(public_path in path for public_path in public_endpoints)
|
is_public = any(public_path in path for public_path in public_endpoints)
|
||||||
if not is_public and path.startswith("/api/"):
|
if not is_public:
|
||||||
operation["security"] = [{"BearerAuth": []}]
|
operation["security"] = [{"BearerAuth": []}]
|
||||||
|
|
||||||
app.openapi_schema = openapi_schema
|
app.openapi_schema = openapi_schema
|
||||||
|
|
@ -319,12 +354,15 @@ add_exception_handlers(app)
|
||||||
|
|
||||||
app.include_router(home_router)
|
app.include_router(home_router)
|
||||||
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
|
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
|
||||||
|
app.include_router(social_account_router, prefix="/user") # Social Account API 라우터 추가
|
||||||
app.include_router(lyric_router)
|
app.include_router(lyric_router)
|
||||||
app.include_router(song_router)
|
app.include_router(song_router)
|
||||||
app.include_router(video_router)
|
app.include_router(video_router)
|
||||||
app.include_router(archive_router) # Archive API 라우터 추가
|
app.include_router(archive_router) # Archive API 라우터 추가
|
||||||
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
||||||
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
|
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 모드에서만 테스트 라우터 등록
|
# DEBUG 모드에서만 테스트 라우터 등록
|
||||||
if prj_settings.DEBUG:
|
if prj_settings.DEBUG:
|
||||||
|
|
|
||||||
|
|
@ -1,694 +0,0 @@
|
||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 1,
|
|
||||||
"id": "e7af5103-62db-4a32-b431-6395c85d7ac9",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"from app.home.api.routers.v1.home import crawling\n",
|
|
||||||
"from app.utils.prompts import prompts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 2,
|
|
||||||
"id": "6cf7ae9b-3ffe-4046-9cab-f33bc071b288",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"from config import crawler_settings"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 3,
|
|
||||||
"id": "4c4ec4c5-9efb-470f-99cf-a18a5b80352f",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"from app.home.schemas.home_schema import (\n",
|
|
||||||
" CrawlingRequest,\n",
|
|
||||||
" CrawlingResponse,\n",
|
|
||||||
" ErrorResponse,\n",
|
|
||||||
" ImageUploadResponse,\n",
|
|
||||||
" ImageUploadResultItem,\n",
|
|
||||||
" ImageUrlItem,\n",
|
|
||||||
" MarketingAnalysis,\n",
|
|
||||||
" ProcessedInfo,\n",
|
|
||||||
")\n",
|
|
||||||
"import json"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 4,
|
|
||||||
"id": "be5d0e16-8cc6-44d4-ae93-8252caa09940",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"val1 = CrawlingRequest(**{\"url\" : 'https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPanelNum=1&additionalHeight=76×tamp=202601131552&locale=ko&svcName=map_pcv5&businessCategory=pension&c=15.00,0,0,0,dh'})"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 5,
|
|
||||||
"id": "c13742d7-70f4-4a6d-90c2-8b84f245a08c",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"from app.utils.prompts.prompts import reload_all_prompt\n",
|
|
||||||
"reload_all_prompt()"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 6,
|
|
||||||
"id": "d4db2ec1-b2af-4993-8832-47f380c17015",
|
|
||||||
"metadata": {
|
|
||||||
"scrolled": true
|
|
||||||
},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"[2026-01-19 14:13:53] [INFO] [home:crawling:110] [crawling] ========== START ==========\n",
|
|
||||||
"[2026-01-19 14:13:53] [INFO] [home:crawling:111] [crawling] URL: https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPane...\n",
|
|
||||||
"[2026-01-19 14:13:53] [INFO] [home:crawling:115] [crawling] Step 1: 네이버 지도 크롤링 시작...\n",
|
|
||||||
"[2026-01-19 14:13:53] [INFO] [scraper:_call_get_accommodation:140] [NvMapScraper] Requesting place_id: 1903455560\n",
|
|
||||||
"[2026-01-19 14:13:53] [INFO] [scraper:_call_get_accommodation:149] [NvMapScraper] SUCCESS - place_id: 1903455560\n",
|
|
||||||
"[2026-01-19 14:13:51] [INFO] [home:crawling:138] [crawling] Step 1 완료 - 이미지 44개 (735.1ms)\n",
|
|
||||||
"[2026-01-19 14:13:51] [INFO] [home:crawling:142] [crawling] Step 2: 정보 가공 시작...\n",
|
|
||||||
"[2026-01-19 14:13:51] [INFO] [home:crawling:159] [crawling] Step 2 완료 - 오블로모프, 군산시 (0.8ms)\n",
|
|
||||||
"[2026-01-19 14:13:51] [INFO] [home:crawling:163] [crawling] Step 3: ChatGPT 마케팅 분석 시작...\n",
|
|
||||||
"[2026-01-19 14:13:51] [DEBUG] [home:crawling:170] [crawling] Step 3-1: 서비스 초기화 완료 (428.6ms)\n",
|
|
||||||
"build_template \n",
|
|
||||||
"[Role & Objective]\n",
|
|
||||||
"Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.\n",
|
|
||||||
"Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.\n",
|
|
||||||
"The report must clearly explain what makes the property sellable, marketable, and scalable through content.\n",
|
|
||||||
"\n",
|
|
||||||
"[INPUT]\n",
|
|
||||||
"- Business Name: {customer_name}\n",
|
|
||||||
"- Region: {region}\n",
|
|
||||||
"- Region Details: {detail_region_info}\n",
|
|
||||||
"\n",
|
|
||||||
"[Core Analysis Requirements]\n",
|
|
||||||
"Analyze the property based on:\n",
|
|
||||||
"Location, concept, and nearby environment\n",
|
|
||||||
"Target customer behavior and reservation decision factors\n",
|
|
||||||
"Include:\n",
|
|
||||||
"- Target customer segments & personas\n",
|
|
||||||
"- Unique Selling Propositions (USPs)\n",
|
|
||||||
"- Competitive landscape (direct & indirect competitors)\n",
|
|
||||||
"- Market positioning\n",
|
|
||||||
"\n",
|
|
||||||
"[Key Selling Point Structuring – UI Optimized]\n",
|
|
||||||
"From the analysis above, extract the main Key Selling Points using the structure below.\n",
|
|
||||||
"Rules:\n",
|
|
||||||
"Focus only on factors that directly influence booking decisions\n",
|
|
||||||
"Each selling point must be concise and visually scannable\n",
|
|
||||||
"Language must be reusable for ads, short-form videos, and listing headlines\n",
|
|
||||||
"Avoid full sentences in descriptions; use short selling phrases\n",
|
|
||||||
"Do not provide in report\n",
|
|
||||||
"\n",
|
|
||||||
"Output format:\n",
|
|
||||||
"[Category]\n",
|
|
||||||
"(Tag keyword – 5~8 words, noun-based, UI oval-style)\n",
|
|
||||||
"One-line selling phrase (not a full sentence)\n",
|
|
||||||
"Limit:\n",
|
|
||||||
"5 to 8 Key Selling Points only\n",
|
|
||||||
"Do not provide in report\n",
|
|
||||||
"\n",
|
|
||||||
"[Content & Automation Readiness Check]\n",
|
|
||||||
"Ensure that:\n",
|
|
||||||
"Each tag keyword can directly map to a content theme\n",
|
|
||||||
"Each selling phrase can be used as:\n",
|
|
||||||
"- Video hook\n",
|
|
||||||
"- Image headline\n",
|
|
||||||
"- Ad copy snippet\n",
|
|
||||||
"\n",
|
|
||||||
"\n",
|
|
||||||
"[Tag Generation Rules]\n",
|
|
||||||
"- Tags must include **only core keywords that can be directly used for viral video song lyrics**\n",
|
|
||||||
"- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind\n",
|
|
||||||
"- The number of tags must be **exactly 5**\n",
|
|
||||||
"- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited\n",
|
|
||||||
"- The following categories must be **balanced and all represented**:\n",
|
|
||||||
" 1) **Location / Local context** (region name, neighborhood, travel context)\n",
|
|
||||||
" 2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)\n",
|
|
||||||
" 3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)\n",
|
|
||||||
" 4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)\n",
|
|
||||||
" 5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)\n",
|
|
||||||
"\n",
|
|
||||||
"- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**\n",
|
|
||||||
"- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**\n",
|
|
||||||
"- The final output must strictly follow the JSON format below, with no additional text\n",
|
|
||||||
"\n",
|
|
||||||
" \"tags\": [\"Tag1\", \"Tag2\", \"Tag3\", \"Tag4\", \"Tag5\"]\n",
|
|
||||||
"\n",
|
|
||||||
"input_data {'customer_name': '오블로모프', 'region': '군산시', 'detail_region_info': '전북 군산시 절골길 16'}\n",
|
|
||||||
"[ChatgptService] Generated Prompt (length: 2791)\n",
|
|
||||||
"[2026-01-19 14:13:51] [INFO] [chatgpt:generate_structured_output:43] [ChatgptService] Starting GPT request with structured output with model: gpt-5-mini\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:187] [crawling] Step 3-3: GPT API 호출 완료 - (63233.5ms)\n",
|
|
||||||
"[2026-01-19 14:14:52] [DEBUG] [home:crawling:188] [crawling] Step 3-3: GPT API 호출 완료 - (63233.5ms)\n",
|
|
||||||
"[2026-01-19 14:14:52] [DEBUG] [home:crawling:193] [crawling] Step 3-4: 응답 파싱 시작 - facility_info: 무선 인터넷, 예약, 주차\n",
|
|
||||||
"[2026-01-19 14:14:52] [DEBUG] [home:crawling:212] [crawling] Step 3-4: 응답 파싱 완료 (2.1ms)\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:215] [crawling] Step 3 완료 - 마케팅 분석 성공 (63670.2ms)\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:229] [crawling] ========== COMPLETE ==========\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:230] [crawling] 총 소요시간: 64412.0ms\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:231] [crawling] - Step 1 (크롤링): 735.1ms\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:233] [crawling] - Step 2 (정보가공): 0.8ms\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:235] [crawling] - Step 3 (GPT 분석): 63670.2ms\n",
|
|
||||||
"[2026-01-19 14:14:52] [INFO] [home:crawling:237] [crawling] - GPT API 호출: 63233.5ms\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"var2 = await crawling(val1)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 7,
|
|
||||||
"id": "79f093f0-d7d2-4ed1-ba43-da06e4ee2073",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"{'image_list': ['https://ldb-phinf.pstatic.net/20230515_163/1684090233619kRU3v_JPEG/20230513_154207.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20250811_213/17548982879808X4MH_PNG/1.png',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_34/1712622373542UY8aC_JPEG/20231007_051403.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_37/1684090234513tT89X_JPEG/20230513_152018.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20241231_272/1735620966755B9XgT_PNG/DSC09054.png',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_100/1712622410472zgP15_JPEG/20230523_153219.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_151/1712623034401FzQbd_JPEG/Screenshot_20240409_093158_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_169/1712622316504ReKji_JPEG/20230728_125946.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230521_279/1684648422643NI2oj_JPEG/20230521_144343.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_52/1712622993632WR1sT_JPEG/Screenshot_20240409_093237_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20250811_151/1754898220223TNtvB_PNG/2.png',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_70/1712622381167p9QOI_JPEG/20230608_175722.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_144/1684090233161cR5mr_JPEG/20230513_180151.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_158/1712621983956CCqdo_JPEG/20240407_121826.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20250811_187/1754893113769iGO5X_JPEG/%B0%C5%BD%C7_01.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_31/17126219901822nnR4_JPEG/20240407_121615.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_94/1712621993863AWMKi_JPEG/20240407_121520.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_165/1684090236297fVhJM_JPEG/20230513_165348.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_102/1684090230350e1v0E_JPEG/20230513_162718.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_26/1684090232743arN2y_JPEG/20230513_174246.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20250811_273/1754893072358V3WcL_JPEG/%B5%F0%C5%D7%C0%CF%C4%C6_02.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_160/1712621974438LLNbD_JPEG/20240407_121848.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_218/1712623006036U39zE_JPEG/Screenshot_20240409_093114_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_210/16840902342654EkeL_JPEG/20230513_152107.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_216/1712623058832HBulg_JPEG/Screenshot_20240409_093309_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_184/1684090223226nO2Az_JPEG/20230514_143325.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_209/1684090697642BHNVR_JPEG/20230514_143528.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_16/1712623029052VNeaz_JPEG/Screenshot_20240409_093141_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_141/1684090233092KwtWy_JPEG/20230513_180105.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_177/1712623066424dcwJ2_JPEG/Screenshot_20240409_093511_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_181/16840902259407iA5Q_JPEG/20230514_144814.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_153/1684090224581Ih4ft_JPEG/20230514_143552.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_205/1684090231467WmulO_JPEG/20230513_180254.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20230515_120/1684090231233PkqCf_JPEG/20230513_152550.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_188/1712623039909sflvy_JPEG/Screenshot_20240409_093209_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_165/1712623049073j0TzM_JPEG/Screenshot_20240409_093254_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_3/17126230950579050V_JPEG/Screenshot_20240409_093412_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_270/1712623091524YX4E6_JPEG/Screenshot_20240409_093355_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_22/1712623083348btwTB_JPEG/Screenshot_20240409_093331_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_242/1712623087423Q7tHk_JPEG/Screenshot_20240409_093339_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_173/1712623098958aFhiB_JPEG/Screenshot_20240409_093422_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_113/1712623103270DOGKI_JPEG/Screenshot_20240409_093435_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_295/17126230704056BTRg_JPEG/Screenshot_20240409_093448_Airbnb.jpg',\n",
|
|
||||||
" 'https://ldb-phinf.pstatic.net/20240409_178/1712623075172JEt43_JPEG/Screenshot_20240409_093457_Airbnb.jpg'],\n",
|
|
||||||
" 'image_count': 44,\n",
|
|
||||||
" 'processed_info': ProcessedInfo(customer_name='오블로모프', region='군산시', detail_region_info='전북 군산시 절골길 16'),\n",
|
|
||||||
" 'marketing_analysis': MarketingAnalysis(report=MarketingAnalysisReport(summary=\"오블로모프는 '느림·쉼·문학적 감성'을 브랜드 콘셉트로 삼아 전북 군산시 절골길 인근의 조용한 주거·근대문화 접근성을 살린 소규모 부티크 스테이입니다. 도심형 접근성과 지역 근대문화·항구 관광지를 결합해 주말 단기체류, 커플·소규모 그룹, 콘텐츠 크리에이터 수요를 공략할 수 있습니다. 핵심은 브랜드 스토리(‘Oblomov’의 느긋함)와 인스타형 비주얼, 지역 연계 체험 상품으로 예약전환을 높이는 것입니다.\", details=[MarketingAnalysisDetail(detail_title='입지·콘셉트·주변 환경', detail_description='절골길 인근의 주택가·언덕형 지형, 조용한 체류 환경. 군산 근대역사문화거리·항구·현지 시장 접근권(차로 10–25분권). 문학적·레트로 감성 콘셉트(오블로모프 → 느림·휴식)으로 도심형 ‘감성 은신처’ 포지셔닝 가능.'), MarketingAnalysisDetail(detail_title='예약 결정 요인(고객 행동)', detail_description='사진·비주얼(첫 인상) → 콘셉트·프라이버시(전용공간 여부) → 접근성(차·대중교통 소요) → 가격 대비 가치·후기 → 체크인 편의성(셀프체크인 여부) → 지역 체험(먹거리·근대문화 투어) 순으로 예약 전환 영향.'), MarketingAnalysisDetail(detail_title='타깃 고객 세그먼트 & 페르소나', detail_description='1) 20–40대 커플: 주말 단기여행, 인생샷·감성 중심. 2) 20–30대 SNS 크리에이터/프리랜서: 콘텐츠·촬영지 탐색. 3) 소규모 가족·친구 그룹: 편안한 휴식·지역먹거리 체험. 4) 도심 직장인(원데이캉스): 근교 드라이브·힐링 목적.'), MarketingAnalysisDetail(detail_title='주요 USP(차별화 포인트)', detail_description='브랜드 스토리(‘Oblomov’ 느림의 미학), 군산 근대문화·항구 접근성, 소규모 부티크·프라이빗 체류감, 감성 포토존·인테리어로 SNS 확산 가능, 지역 먹거리·투어 연계로 체류 체감 가치 상승.'), MarketingAnalysisDetail(detail_title='경쟁 환경(직·간접 경쟁)', detail_description=\"직접: 군산 내 펜션·게스트하우스·한옥스테이(근대문화거리·항구 인근). 간접: 근교 글램핑·리조트·카페형 숙소, 당일투어(시장·박물관)로 체류대체 가능. 경쟁 우위는 '문학적 느림' 콘셉트+인스타블 친화적 비주얼.\"), MarketingAnalysisDetail(detail_title='시장 포지셔닝 제안', detail_description=\"중간 가격대의 부티크 스테이(가성비+감성), '주말 힐링·감성 촬영지' 중심 마케팅. 타깃 채널: 네이버 예약·에어비앤비·인스타그램·유튜브 숏폼. 지역 협업(카페·투어·해산물 체험)으로 패키지화.\")]), tags=['군산오블로모프', '부티크스테이', '힐링타임', '인생샷스팟', '주말여행'], facilities=['군산 근대거리·항구 근접', '문학적 느림·부티크 스테이', '프라이빗 객실·소규모 전용감', '감성 포토존·인테리어', '해산물·시장·근대투어 연계', '주말 단기여행·원데이캉스 수요'])}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 7,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"var2"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 8,
|
|
||||||
"id": "f3bf1d76-bd2a-43d5-8d39-f0ab2459701a",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"ename": "KeyError",
|
|
||||||
"evalue": "'selling_points'",
|
|
||||||
"output_type": "error",
|
|
||||||
"traceback": [
|
|
||||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
|
||||||
"\u001b[31mKeyError\u001b[39m Traceback (most recent call last)",
|
|
||||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[8]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m i \u001b[38;5;129;01min\u001b[39;00m \u001b[43mvar2\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mselling_points\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m]\u001b[49m:\n\u001b[32m 2\u001b[39m \u001b[38;5;28mprint\u001b[39m(i[\u001b[33m'\u001b[39m\u001b[33mcategory\u001b[39m\u001b[33m'\u001b[39m])\n\u001b[32m 3\u001b[39m \u001b[38;5;28mprint\u001b[39m(i[\u001b[33m'\u001b[39m\u001b[33mkeywords\u001b[39m\u001b[33m'\u001b[39m])\n",
|
|
||||||
"\u001b[31mKeyError\u001b[39m: 'selling_points'"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"for i in var2[\"selling_points\"]:\n",
|
|
||||||
" print(i['category'])\n",
|
|
||||||
" print(i['keywords'])\n",
|
|
||||||
" print(i['description'])"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "c89cf2eb-4f16-4dc5-90c6-df89191b4e39",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"var2[\"selling_points\"]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "231963d6-e209-41b3-8e78-2ad5d06943fe",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"var2[\"tags\"]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "f8260222-d5a2-4018-b465-a4943c82bd3f",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"lyric_prompt = \"\"\"\n",
|
|
||||||
"[ROLE]\n",
|
|
||||||
"You are a content marketing expert, brand strategist, and creative songwriter\n",
|
|
||||||
"specializing in Korean pension / accommodation businesses.\n",
|
|
||||||
"You create lyrics strictly based on Brand & Marketing Intelligence analysis\n",
|
|
||||||
"and optimized for viral short-form video content.\n",
|
|
||||||
"\n",
|
|
||||||
"[INPUT]\n",
|
|
||||||
"Business Name: {customer_name}\n",
|
|
||||||
"Region: {region}\n",
|
|
||||||
"Region Details: {detail_region_info}\n",
|
|
||||||
"Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n",
|
|
||||||
"Output Language: {language}\n",
|
|
||||||
"\n",
|
|
||||||
"[INTERNAL ANALYSIS – DO NOT OUTPUT]\n",
|
|
||||||
"Internally analyze the following to guide all creative decisions:\n",
|
|
||||||
"- Core brand identity and positioning\n",
|
|
||||||
"- Emotional hooks derived from selling points\n",
|
|
||||||
"- Target audience lifestyle, desires, and travel motivation\n",
|
|
||||||
"- Regional atmosphere and symbolic imagery\n",
|
|
||||||
"- How the stay converts into “shareable moments”\n",
|
|
||||||
"- Which selling points must surface implicitly in lyrics\n",
|
|
||||||
"\n",
|
|
||||||
"[LYRICS & MUSIC CREATION TASK]\n",
|
|
||||||
"Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n",
|
|
||||||
"- Original promotional lyrics\n",
|
|
||||||
"- Music attributes for AI music generation (Suno-compatible prompt)\n",
|
|
||||||
"The output must be designed for VIRAL DIGITAL CONTENT\n",
|
|
||||||
"(short-form video, reels, ads).\n",
|
|
||||||
"\n",
|
|
||||||
"[LYRICS REQUIREMENTS]\n",
|
|
||||||
"Mandatory Inclusions:\n",
|
|
||||||
"- Business name\n",
|
|
||||||
"- Region name\n",
|
|
||||||
"- Promotion subject\n",
|
|
||||||
"- Promotional expressions including:\n",
|
|
||||||
"{promotional_expressions[language]}\n",
|
|
||||||
"\n",
|
|
||||||
"Content Rules:\n",
|
|
||||||
"- Lyrics must be emotionally driven, not descriptive listings\n",
|
|
||||||
"- Selling points must be IMPLIED, not explained\n",
|
|
||||||
"- Must sound natural when sung\n",
|
|
||||||
"- Must feel like a lifestyle moment, not an advertisement\n",
|
|
||||||
"\n",
|
|
||||||
"Tone & Style:\n",
|
|
||||||
"- Warm, emotional, and aspirational\n",
|
|
||||||
"- Trendy, viral-friendly phrasing\n",
|
|
||||||
"- Calm but memorable hooks\n",
|
|
||||||
"- Suitable for travel / stay-related content\n",
|
|
||||||
"\n",
|
|
||||||
"[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n",
|
|
||||||
"After the lyrics, generate a concise music prompt including:\n",
|
|
||||||
"Song mood (emotional keywords)\n",
|
|
||||||
"BPM range\n",
|
|
||||||
"Recommended genres (max 2)\n",
|
|
||||||
"Key musical motifs or instruments\n",
|
|
||||||
"Overall vibe (1 short sentence)\n",
|
|
||||||
"\n",
|
|
||||||
"[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n",
|
|
||||||
"ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n",
|
|
||||||
"no mixed languages\n",
|
|
||||||
"All names, places, and expressions must be in {language} \n",
|
|
||||||
"Any violation invalidates the entire output\n",
|
|
||||||
"\n",
|
|
||||||
"[OUTPUT RULES – STRICT]\n",
|
|
||||||
"{timing_rules}\n",
|
|
||||||
"8–12 lines\n",
|
|
||||||
"Full verse flow, immersive mood\n",
|
|
||||||
"\n",
|
|
||||||
"No explanations\n",
|
|
||||||
"No headings\n",
|
|
||||||
"No bullet points\n",
|
|
||||||
"No analysis\n",
|
|
||||||
"No extra text\n",
|
|
||||||
"\n",
|
|
||||||
"[FAILURE FORMAT]\n",
|
|
||||||
"If generation is impossible:\n",
|
|
||||||
"ERROR: Brief reason in English\n",
|
|
||||||
"\"\"\"\n",
|
|
||||||
"lyric_prompt_dict = {\n",
|
|
||||||
" \"prompt_variables\" :\n",
|
|
||||||
" [\n",
|
|
||||||
" \"customer_name\",\n",
|
|
||||||
" \"region\",\n",
|
|
||||||
" \"detail_region_info\",\n",
|
|
||||||
" \"marketing_intelligence_summary\",\n",
|
|
||||||
" \"language\",\n",
|
|
||||||
" \"promotional_expression_example\",\n",
|
|
||||||
" \"timing_rules\",\n",
|
|
||||||
" \n",
|
|
||||||
" ],\n",
|
|
||||||
" \"output_format\" : {\n",
|
|
||||||
" \"format\": {\n",
|
|
||||||
" \"type\": \"json_schema\",\n",
|
|
||||||
" \"name\": \"lyric\",\n",
|
|
||||||
" \"schema\": {\n",
|
|
||||||
" \"type\":\"object\",\n",
|
|
||||||
" \"properties\" : {\n",
|
|
||||||
" \"lyric\" : { \n",
|
|
||||||
" \"type\" : \"string\"\n",
|
|
||||||
" }\n",
|
|
||||||
" },\n",
|
|
||||||
" \"required\": [\"lyric\"],\n",
|
|
||||||
" \"additionalProperties\": False,\n",
|
|
||||||
" },\n",
|
|
||||||
" \"strict\": True\n",
|
|
||||||
" }\n",
|
|
||||||
" }\n",
|
|
||||||
"}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 4,
|
|
||||||
"id": "79edd82b-6f4c-43c7-9205-0b970afe06d7",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"\n",
|
|
||||||
"with open(\"./app/utils/prompts/marketing_prompt.txt\", \"w\") as fp:\n",
|
|
||||||
" fp.write(marketing_prompt)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 17,
|
|
||||||
"id": "65a5a2a6-06a5-4ee1-a796-406c86aefc20",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"with open(\"prompts/summarize_prompt.json\", \"r\") as fp:\n",
|
|
||||||
" p = json.load(fp)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 18,
|
|
||||||
"id": "454d920f-e9ed-4fb2-806c-75b8f7033db9",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"{'prompt_variables': ['report', 'selling_points'],\n",
|
|
||||||
" 'prompt': '\\n입력 : \\n분석 보고서\\n{report}\\n\\n셀링 포인트\\n{selling_points}\\n\\n위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.\\n\\n조건:\\n각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것\\n태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여\\n- 3 ~ 6단어 이내\\n- 명사 또는 명사형 키워드로 작성\\n- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것\\n- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함\\n- 전체 셀링 포인트 개수는 5~7개로 제한\\n\\n출력 형식:\\n[카테고리명]\\n(태그 키워드)\\n- 한 줄 설명 문구\\n\\n예시: \\n[공간 정체성]\\n(100년 적산가옥 · 시간의 결)\\n- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간\\n\\n[입지 & 희소성]\\n(말랭이마을 · 로컬 히든플레이스)\\n- 관광지가 아닌, 군산을 아는 사람의 선택\\n\\n[프라이버시]\\n(독채 숙소 · 프라이빗 스테이)\\n- 누구의 방해도 없는 완전한 휴식 구조\\n\\n[비주얼 경쟁력]\\n(감성 인테리어 · 자연광 스폿)\\n- 찍는 순간 콘텐츠가 되는 공간 설계\\n\\n[타깃 최적화]\\n(커플 · 소규모 여행)\\n- 둘에게 가장 이상적인 공간 밀도\\n\\n[체류 경험]\\n(아무것도 안 해도 되는 하루)\\n- 일정 없이도 만족되는 하루 루틴\\n\\n[브랜드 포지션]\\n(호텔도 펜션도 아닌 아지트)\\n- 다시 돌아오고 싶은 개인적 장소\\n ',\n",
|
|
||||||
" 'output_format': {'format': {'type': 'json_schema',\n",
|
|
||||||
" 'name': 'tags',\n",
|
|
||||||
" 'schema': {'type': 'object',\n",
|
|
||||||
" 'properties': {'category': {'type': 'string'},\n",
|
|
||||||
" 'tag_keywords': {'type': 'string'},\n",
|
|
||||||
" 'description': {'type': 'string'}},\n",
|
|
||||||
" 'required': ['category', 'tag_keywords', 'description'],\n",
|
|
||||||
" 'additionalProperties': False},\n",
|
|
||||||
" 'strict': True}}}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 18,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"p"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 3,
|
|
||||||
"id": "c46abcda-d6a8-485e-92f1-526fb28c6b53",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import json\n",
|
|
||||||
"marketing_prompt_dict = {\n",
|
|
||||||
" \"model\" : \"gpt-5-mini\",\n",
|
|
||||||
" \"prompt_variables\" :\n",
|
|
||||||
" [\n",
|
|
||||||
" \"customer_name\",\n",
|
|
||||||
" \"region\",\n",
|
|
||||||
" \"detail_region_info\"\n",
|
|
||||||
" ],\n",
|
|
||||||
" \"output_format\" : {\n",
|
|
||||||
" \"format\": {\n",
|
|
||||||
" \"type\": \"json_schema\",\n",
|
|
||||||
" \"name\": \"report\",\n",
|
|
||||||
" \"schema\": {\n",
|
|
||||||
" \"type\" : \"object\",\n",
|
|
||||||
" \"properties\" : {\n",
|
|
||||||
" \"report\" : {\n",
|
|
||||||
" \"type\": \"object\",\n",
|
|
||||||
" \"properties\" : {\n",
|
|
||||||
" \"summary\" : {\"type\" : \"string\"},\n",
|
|
||||||
" \"details\" : {\n",
|
|
||||||
" \"type\" : \"array\",\n",
|
|
||||||
" \"items\" : {\n",
|
|
||||||
" \"type\": \"object\",\n",
|
|
||||||
" \"properties\" : {\n",
|
|
||||||
" \"detail_title\" : {\"type\" : \"string\"},\n",
|
|
||||||
" \"detail_description\" : {\"type\" : \"string\"},\n",
|
|
||||||
" },\n",
|
|
||||||
" \"required\": [\"detail_title\", \"detail_description\"],\n",
|
|
||||||
" \"additionalProperties\": False,\n",
|
|
||||||
" }\n",
|
|
||||||
" }\n",
|
|
||||||
" },\n",
|
|
||||||
" \"required\" : [\"summary\", \"details\"],\n",
|
|
||||||
" \"additionalProperties\" : False\n",
|
|
||||||
" },\n",
|
|
||||||
" \"selling_points\" : {\n",
|
|
||||||
" \"type\": \"array\",\n",
|
|
||||||
" \"items\": {\n",
|
|
||||||
" \"type\": \"object\",\n",
|
|
||||||
" \"properties\" : {\n",
|
|
||||||
" \"category\" : {\"type\" : \"string\"},\n",
|
|
||||||
" \"keywords\" : {\"type\" : \"string\"},\n",
|
|
||||||
" \"description\" : {\"type\" : \"string\"}\n",
|
|
||||||
" },\n",
|
|
||||||
" \"required\": [\"category\", \"keywords\", \"description\"],\n",
|
|
||||||
" \"additionalProperties\": False,\n",
|
|
||||||
" },\n",
|
|
||||||
" },\n",
|
|
||||||
" \"tags\" : {\n",
|
|
||||||
" \"type\": \"array\",\n",
|
|
||||||
" \"items\": {\n",
|
|
||||||
" \"type\": \"string\"\n",
|
|
||||||
" },\n",
|
|
||||||
" },\n",
|
|
||||||
" \"contents_advise\" : {\"type\" : \"string\"}\n",
|
|
||||||
" },\n",
|
|
||||||
" \"required\": [\"report\", \"selling_points\", \"tags\", \"contents_advise\"],\n",
|
|
||||||
" \"additionalProperties\": False,\n",
|
|
||||||
" },\n",
|
|
||||||
" \"strict\": True\n",
|
|
||||||
" }\n",
|
|
||||||
" }\n",
|
|
||||||
"}\n",
|
|
||||||
"with open(\"./app/utils/prompts/marketing_prompt.json\", \"w\") as fp:\n",
|
|
||||||
" json.dump(marketing_prompt_dict, fp, ensure_ascii=False)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 15,
|
|
||||||
"id": "c3867dab-0c4e-46be-ad12-a9c02b5edb68",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"lyric_prompt = \"\"\"\n",
|
|
||||||
"[ROLE]\n",
|
|
||||||
"You are a content marketing expert, brand strategist, and creative songwriter\n",
|
|
||||||
"specializing in Korean pension / accommodation businesses.\n",
|
|
||||||
"You create lyrics strictly based on Brand & Marketing Intelligence analysis\n",
|
|
||||||
"and optimized for viral short-form video content.\n",
|
|
||||||
"\n",
|
|
||||||
"[INPUT]\n",
|
|
||||||
"Business Name: {customer_name}\n",
|
|
||||||
"Region: {region}\n",
|
|
||||||
"Region Details: {detail_region_info}\n",
|
|
||||||
"Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n",
|
|
||||||
"Output Language: {language}\n",
|
|
||||||
"\n",
|
|
||||||
"[INTERNAL ANALYSIS – DO NOT OUTPUT]\n",
|
|
||||||
"Internally analyze the following to guide all creative decisions:\n",
|
|
||||||
"- Core brand identity and positioning\n",
|
|
||||||
"- Emotional hooks derived from selling points\n",
|
|
||||||
"- Target audience lifestyle, desires, and travel motivation\n",
|
|
||||||
"- Regional atmosphere and symbolic imagery\n",
|
|
||||||
"- How the stay converts into “shareable moments”\n",
|
|
||||||
"- Which selling points must surface implicitly in lyrics\n",
|
|
||||||
"\n",
|
|
||||||
"[LYRICS & MUSIC CREATION TASK]\n",
|
|
||||||
"Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n",
|
|
||||||
"- Original promotional lyrics\n",
|
|
||||||
"- Music attributes for AI music generation (Suno-compatible prompt)\n",
|
|
||||||
"The output must be designed for VIRAL DIGITAL CONTENT\n",
|
|
||||||
"(short-form video, reels, ads).\n",
|
|
||||||
"\n",
|
|
||||||
"[LYRICS REQUIREMENTS]\n",
|
|
||||||
"Mandatory Inclusions:\n",
|
|
||||||
"- Business name\n",
|
|
||||||
"- Region name\n",
|
|
||||||
"- Promotion subject\n",
|
|
||||||
"- Promotional expressions including:\n",
|
|
||||||
"{promotional_expressions[language]}\n",
|
|
||||||
"\n",
|
|
||||||
"Content Rules:\n",
|
|
||||||
"- Lyrics must be emotionally driven, not descriptive listings\n",
|
|
||||||
"- Selling points must be IMPLIED, not explained\n",
|
|
||||||
"- Must sound natural when sung\n",
|
|
||||||
"- Must feel like a lifestyle moment, not an advertisement\n",
|
|
||||||
"\n",
|
|
||||||
"Tone & Style:\n",
|
|
||||||
"- Warm, emotional, and aspirational\n",
|
|
||||||
"- Trendy, viral-friendly phrasing\n",
|
|
||||||
"- Calm but memorable hooks\n",
|
|
||||||
"- Suitable for travel / stay-related content\n",
|
|
||||||
"\n",
|
|
||||||
"[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n",
|
|
||||||
"After the lyrics, generate a concise music prompt including:\n",
|
|
||||||
"Song mood (emotional keywords)\n",
|
|
||||||
"BPM range\n",
|
|
||||||
"Recommended genres (max 2)\n",
|
|
||||||
"Key musical motifs or instruments\n",
|
|
||||||
"Overall vibe (1 short sentence)\n",
|
|
||||||
"\n",
|
|
||||||
"[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n",
|
|
||||||
"ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n",
|
|
||||||
"no mixed languages\n",
|
|
||||||
"All names, places, and expressions must be in {language} \n",
|
|
||||||
"Any violation invalidates the entire output\n",
|
|
||||||
"\n",
|
|
||||||
"[OUTPUT RULES – STRICT]\n",
|
|
||||||
"{timing_rules}\n",
|
|
||||||
"8–12 lines\n",
|
|
||||||
"Full verse flow, immersive mood\n",
|
|
||||||
"\n",
|
|
||||||
"No explanations\n",
|
|
||||||
"No headings\n",
|
|
||||||
"No bullet points\n",
|
|
||||||
"No analysis\n",
|
|
||||||
"No extra text\n",
|
|
||||||
"\n",
|
|
||||||
"[FAILURE FORMAT]\n",
|
|
||||||
"If generation is impossible:\n",
|
|
||||||
"ERROR: Brief reason in English\n",
|
|
||||||
"\"\"\"\n",
|
|
||||||
"with open(\"./app/utils/prompts/lyric_prompt.txt\", \"w\") as fp:\n",
|
|
||||||
" fp.write(lyric_prompt)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 14,
|
|
||||||
"id": "5736ca4b-c379-4cae-84a9-534cad9576c7",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"lyric_prompt_dict = {\n",
|
|
||||||
" \"model\" : \"gpt-5-mini\",\n",
|
|
||||||
" \"prompt_variables\" :\n",
|
|
||||||
" [\n",
|
|
||||||
" \"customer_name\",\n",
|
|
||||||
" \"region\",\n",
|
|
||||||
" \"detail_region_info\",\n",
|
|
||||||
" \"marketing_intelligence_summary\",\n",
|
|
||||||
" \"language\",\n",
|
|
||||||
" \"promotional_expression_example\",\n",
|
|
||||||
" \"timing_rules\",\n",
|
|
||||||
" \n",
|
|
||||||
" ],\n",
|
|
||||||
" \"output_format\" : {\n",
|
|
||||||
" \"format\": {\n",
|
|
||||||
" \"type\": \"json_schema\",\n",
|
|
||||||
" \"name\": \"lyric\",\n",
|
|
||||||
" \"schema\": {\n",
|
|
||||||
" \"type\":\"object\",\n",
|
|
||||||
" \"properties\" : {\n",
|
|
||||||
" \"lyric\" : { \n",
|
|
||||||
" \"type\" : \"string\"\n",
|
|
||||||
" }\n",
|
|
||||||
" },\n",
|
|
||||||
" \"required\": [\"lyric\"],\n",
|
|
||||||
" \"additionalProperties\": False,\n",
|
|
||||||
" },\n",
|
|
||||||
" \"strict\": True\n",
|
|
||||||
" }\n",
|
|
||||||
" }\n",
|
|
||||||
"}\n",
|
|
||||||
"with open(\"./app/utils/prompts/lyric_prompt.json\", \"w\") as fp:\n",
|
|
||||||
" json.dump(lyric_prompt_dict, fp, ensure_ascii=False)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "430c8914-4e6a-4b53-8903-f454e7ccb8e2",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "Python 3 (ipykernel)",
|
|
||||||
"language": "python",
|
|
||||||
"name": "python3"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"codemirror_mode": {
|
|
||||||
"name": "ipython",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"file_extension": ".py",
|
|
||||||
"mimetype": "text/x-python",
|
|
||||||
"name": "python",
|
|
||||||
"nbconvert_exporter": "python",
|
|
||||||
"pygments_lexer": "ipython3",
|
|
||||||
"version": "3.13.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API POC 패키지
|
||||||
|
|
||||||
|
단일 클래스로 구현된 Instagram Graph API 클라이언트입니다.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from poc.instagram import InstagramClient
|
||||||
|
|
||||||
|
async with InstagramClient(access_token="YOUR_TOKEN") as client:
|
||||||
|
media = await client.publish_image(
|
||||||
|
image_url="https://example.com/image.jpg",
|
||||||
|
caption="Hello!"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
from poc.instagram.client import (
|
||||||
|
InstagramClient,
|
||||||
|
ErrorState,
|
||||||
|
parse_instagram_error,
|
||||||
|
)
|
||||||
|
from poc.instagram.sns_schema import (
|
||||||
|
Media,
|
||||||
|
MediaList,
|
||||||
|
MediaContainer,
|
||||||
|
APIError,
|
||||||
|
ErrorResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Client
|
||||||
|
"InstagramClient",
|
||||||
|
# Error handling
|
||||||
|
"ErrorState",
|
||||||
|
"parse_instagram_error",
|
||||||
|
# Models
|
||||||
|
"Media",
|
||||||
|
"MediaList",
|
||||||
|
"MediaContainer",
|
||||||
|
"APIError",
|
||||||
|
"ErrorResponse",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API Client
|
||||||
|
|
||||||
|
Instagram Graph API를 사용한 비디오/릴스 게시를 위한 비동기 클라이언트입니다.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with InstagramClient(access_token="YOUR_TOKEN") as client:
|
||||||
|
media = await client.publish_video(
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
caption="Hello Instagram!"
|
||||||
|
)
|
||||||
|
print(f"게시 완료: {media.permalink}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .sns_schema import ErrorResponse, Media, MediaContainer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Error State & Parser
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorState(str, Enum):
|
||||||
|
"""Instagram API 에러 상태"""
|
||||||
|
|
||||||
|
RATE_LIMIT = "rate_limit"
|
||||||
|
AUTH_ERROR = "auth_error"
|
||||||
|
CONTAINER_TIMEOUT = "container_timeout"
|
||||||
|
CONTAINER_ERROR = "container_error"
|
||||||
|
API_ERROR = "api_error"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_instagram_error(e: Exception) -> tuple[ErrorState, str, dict]:
|
||||||
|
"""
|
||||||
|
Instagram 예외를 파싱하여 상태, 메시지, 추가 정보를 반환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
e: 발생한 예외
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (error_state, message, extra_info)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> error_state, message, extra_info = parse_instagram_error(e)
|
||||||
|
>>> if error_state == ErrorState.RATE_LIMIT:
|
||||||
|
... retry_after = extra_info.get("retry_after", 60)
|
||||||
|
"""
|
||||||
|
error_str = str(e)
|
||||||
|
extra_info = {}
|
||||||
|
|
||||||
|
# Rate Limit 에러
|
||||||
|
if "[RateLimit]" in error_str:
|
||||||
|
match = re.search(r"retry_after=(\d+)s", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["retry_after"] = int(match.group(1))
|
||||||
|
return ErrorState.RATE_LIMIT, "API 호출 제한 초과", extra_info
|
||||||
|
|
||||||
|
# 인증 에러 (code=190)
|
||||||
|
if "code=190" in error_str:
|
||||||
|
return ErrorState.AUTH_ERROR, "인증 실패 (토큰 만료 또는 무효)", extra_info
|
||||||
|
|
||||||
|
# 컨테이너 타임아웃
|
||||||
|
if "[ContainerTimeout]" in error_str:
|
||||||
|
match = re.search(r"\((\d+)초 초과\)", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["timeout"] = int(match.group(1))
|
||||||
|
return ErrorState.CONTAINER_TIMEOUT, "미디어 처리 시간 초과", extra_info
|
||||||
|
|
||||||
|
# 컨테이너 상태 에러
|
||||||
|
if "[ContainerStatus]" in error_str:
|
||||||
|
match = re.search(r"처리 실패: (\w+)", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["status"] = match.group(1)
|
||||||
|
return ErrorState.CONTAINER_ERROR, "미디어 컨테이너 처리 실패", extra_info
|
||||||
|
|
||||||
|
# Instagram API 에러
|
||||||
|
if "[InstagramAPI]" in error_str:
|
||||||
|
match = re.search(r"code=(\d+)", error_str)
|
||||||
|
if match:
|
||||||
|
extra_info["code"] = int(match.group(1))
|
||||||
|
return ErrorState.API_ERROR, "Instagram API 오류", extra_info
|
||||||
|
|
||||||
|
return ErrorState.UNKNOWN, str(e), extra_info
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Instagram Client
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramClient:
|
||||||
|
"""
|
||||||
|
Instagram Graph API 비동기 클라이언트 (비디오 업로드 전용)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with InstagramClient(access_token="USER_TOKEN") as client:
|
||||||
|
media = await client.publish_video(
|
||||||
|
video_url="https://example.com/video.mp4",
|
||||||
|
caption="My video!"
|
||||||
|
)
|
||||||
|
print(f"게시됨: {media.permalink}")
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://graph.instagram.com/v21.0"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
*,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
max_retries: int = 3,
|
||||||
|
container_timeout: float = 300.0,
|
||||||
|
container_poll_interval: float = 5.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
클라이언트 초기화
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Instagram 액세스 토큰 (필수)
|
||||||
|
base_url: API 기본 URL (기본값: https://graph.instagram.com/v21.0)
|
||||||
|
timeout: HTTP 요청 타임아웃 (초)
|
||||||
|
max_retries: 최대 재시도 횟수
|
||||||
|
container_timeout: 컨테이너 처리 대기 타임아웃 (초)
|
||||||
|
container_poll_interval: 컨테이너 상태 확인 간격 (초)
|
||||||
|
"""
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("access_token은 필수입니다.")
|
||||||
|
|
||||||
|
self.access_token = access_token
|
||||||
|
self.base_url = base_url or self.DEFAULT_BASE_URL
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.container_timeout = container_timeout
|
||||||
|
self.container_poll_interval = container_poll_interval
|
||||||
|
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
self._account_id: Optional[str] = None
|
||||||
|
self._account_id_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "InstagramClient":
|
||||||
|
"""비동기 컨텍스트 매니저 진입"""
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(self.timeout),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
logger.debug("[InstagramClient] HTTP 클라이언트 초기화 완료")
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""비동기 컨텍스트 매니저 종료"""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
logger.debug("[InstagramClient] HTTP 클라이언트 종료")
|
||||||
|
|
||||||
|
def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""HTTP 클라이언트 반환"""
|
||||||
|
if self._client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"InstagramClient는 비동기 컨텍스트 매니저로 사용해야 합니다. "
|
||||||
|
"예: async with InstagramClient(access_token=...) as client:"
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _build_url(self, endpoint: str) -> str:
|
||||||
|
"""API URL 생성"""
|
||||||
|
return f"{self.base_url}/{endpoint}"
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[dict[str, Any]] = None,
|
||||||
|
data: Optional[dict[str, Any]] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
공통 HTTP 요청 처리
|
||||||
|
|
||||||
|
- Rate Limit 시 지수 백오프 재시도
|
||||||
|
- 에러 응답 시 InstagramAPIError 발생
|
||||||
|
"""
|
||||||
|
client = self._get_client()
|
||||||
|
url = self._build_url(endpoint)
|
||||||
|
params = params or {}
|
||||||
|
params["access_token"] = self.access_token
|
||||||
|
|
||||||
|
retry_base_delay = 1.0
|
||||||
|
last_exception: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
try:
|
||||||
|
logger.debug(
|
||||||
|
f"[API] {method} {endpoint} (attempt {attempt + 1}/{self.max_retries + 1})"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rate Limit 체크 (429)
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = int(response.headers.get("Retry-After", 60))
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
wait_time = max(retry_base_delay * (2**attempt), retry_after)
|
||||||
|
logger.warning(f"Rate limit 초과. {wait_time}초 후 재시도...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
raise Exception(
|
||||||
|
f"[RateLimit] Rate limit 초과 (최대 재시도 횟수 도달) | retry_after={retry_after}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 서버 에러 재시도 (5xx)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
wait_time = retry_base_delay * (2**attempt)
|
||||||
|
logger.warning(f"서버 에러 {response.status_code}. {wait_time}초 후 재시도...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# JSON 파싱
|
||||||
|
response_data = response.json()
|
||||||
|
|
||||||
|
# API 에러 체크 (Instagram API는 200 응답에도 error 포함 가능)
|
||||||
|
if "error" in response_data:
|
||||||
|
error_response = ErrorResponse.model_validate(response_data)
|
||||||
|
err = error_response.error
|
||||||
|
logger.error(f"[API Error] code={err.code}, message={err.message}")
|
||||||
|
error_msg = f"[InstagramAPI] {err.message} | code={err.code}"
|
||||||
|
if err.error_subcode:
|
||||||
|
error_msg += f" | subcode={err.error_subcode}"
|
||||||
|
if err.fbtrace_id:
|
||||||
|
error_msg += f" | fbtrace_id={err.fbtrace_id}"
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
last_exception = e
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
wait_time = retry_base_delay * (2**attempt)
|
||||||
|
logger.warning(f"HTTP 에러: {e}. {wait_time}초 후 재시도...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise last_exception or Exception("[InstagramAPI] 최대 재시도 횟수 초과")
|
||||||
|
|
||||||
|
async def _wait_for_container(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
) -> MediaContainer:
|
||||||
|
"""컨테이너 상태가 FINISHED가 될 때까지 대기"""
|
||||||
|
timeout = timeout or self.container_timeout
|
||||||
|
start_time = time.monotonic()
|
||||||
|
|
||||||
|
logger.debug(f"[Container] 대기 시작: {container_id}, timeout={timeout}s")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
elapsed = time.monotonic() - start_time
|
||||||
|
if elapsed >= timeout:
|
||||||
|
raise Exception(
|
||||||
|
f"[ContainerTimeout] 컨테이너 처리 타임아웃 ({timeout}초 초과): {container_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self._request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=container_id,
|
||||||
|
params={"fields": "status_code,status"},
|
||||||
|
)
|
||||||
|
|
||||||
|
container = MediaContainer.model_validate(response)
|
||||||
|
logger.debug(f"[Container] status={container.status_code}, elapsed={elapsed:.1f}s")
|
||||||
|
|
||||||
|
if container.is_finished:
|
||||||
|
logger.info(f"[Container] 완료: {container_id}")
|
||||||
|
return container
|
||||||
|
|
||||||
|
if container.is_error:
|
||||||
|
raise Exception(f"[ContainerStatus] 컨테이너 처리 실패: {container.status}")
|
||||||
|
|
||||||
|
await asyncio.sleep(self.container_poll_interval)
|
||||||
|
|
||||||
|
async def get_account_id(self) -> str:
|
||||||
|
"""계정 ID 조회 (접속 테스트용)"""
|
||||||
|
if self._account_id:
|
||||||
|
return self._account_id
|
||||||
|
|
||||||
|
async with self._account_id_lock:
|
||||||
|
if self._account_id:
|
||||||
|
return self._account_id
|
||||||
|
|
||||||
|
response = await self._request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="me",
|
||||||
|
params={"fields": "id"},
|
||||||
|
)
|
||||||
|
account_id: str = response["id"]
|
||||||
|
self._account_id = account_id
|
||||||
|
logger.debug(f"[Account] ID 조회 완료: {account_id}")
|
||||||
|
return account_id
|
||||||
|
|
||||||
|
async def get_media(self, media_id: str) -> Media:
|
||||||
|
"""
|
||||||
|
미디어 상세 조회
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_id: 미디어 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Media: 미디어 상세 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"[get_media] media_id={media_id}")
|
||||||
|
|
||||||
|
response = await self._request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=media_id,
|
||||||
|
params={
|
||||||
|
"fields": "id,media_type,media_url,thumbnail_url,caption,timestamp,permalink,like_count,comments_count",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Media.model_validate(response)
|
||||||
|
logger.info(f"[get_media] 완료: type={result.media_type}, permalink={result.permalink}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def publish_video(
|
||||||
|
self,
|
||||||
|
video_url: str,
|
||||||
|
caption: Optional[str] = None,
|
||||||
|
share_to_feed: bool = True,
|
||||||
|
) -> Media:
|
||||||
|
"""
|
||||||
|
비디오/릴스 게시
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_url: 공개 접근 가능한 비디오 URL (MP4 권장)
|
||||||
|
caption: 게시물 캡션
|
||||||
|
share_to_feed: 피드에 공유 여부
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Media: 게시된 미디어 정보
|
||||||
|
"""
|
||||||
|
logger.info(f"[publish_video] 시작: {video_url[:50]}...")
|
||||||
|
account_id = await self.get_account_id()
|
||||||
|
|
||||||
|
# Step 1: Container 생성
|
||||||
|
container_params: dict[str, Any] = {
|
||||||
|
"media_type": "REELS",
|
||||||
|
"video_url": video_url,
|
||||||
|
"share_to_feed": str(share_to_feed).lower(),
|
||||||
|
}
|
||||||
|
if caption:
|
||||||
|
container_params["caption"] = caption
|
||||||
|
|
||||||
|
container_response = await self._request(
|
||||||
|
method="POST",
|
||||||
|
endpoint=f"{account_id}/media",
|
||||||
|
params=container_params,
|
||||||
|
)
|
||||||
|
container_id = container_response["id"]
|
||||||
|
logger.debug(f"[publish_video] Container 생성: {container_id}")
|
||||||
|
|
||||||
|
# Step 2: Container 상태 대기 (비디오는 더 오래 걸림)
|
||||||
|
await self._wait_for_container(container_id, timeout=self.container_timeout * 2)
|
||||||
|
|
||||||
|
# Step 3: 게시
|
||||||
|
publish_response = await self._request(
|
||||||
|
method="POST",
|
||||||
|
endpoint=f"{account_id}/media_publish",
|
||||||
|
params={"creation_id": container_id},
|
||||||
|
)
|
||||||
|
media_id = publish_response["id"]
|
||||||
|
|
||||||
|
result = await self.get_media(media_id)
|
||||||
|
logger.info(f"[publish_video] 완료: {result.permalink}")
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API POC - 비디오 업로드 테스트
|
||||||
|
|
||||||
|
실행 방법:
|
||||||
|
python -m poc.instagram.main
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from poc.instagram import InstagramClient, ErrorState, parse_instagram_error
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 설정
|
||||||
|
ACCESS_TOKEN = ""
|
||||||
|
|
||||||
|
VIDEO_URL2 = "https://f002.backblazeb2.com/file/creatomate-c8xg3hsxdu/9b1a680b-3481-4b22-94d4-a5cfd3e19f95.mp4"
|
||||||
|
|
||||||
|
VIDEO_URL3 = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1c1c-e311-756d-8635-bfe62898f73e/019c1c1d-1a3e-78c9-819a-a9de16f487c7/video/스테이머뭄.mp4"
|
||||||
|
|
||||||
|
VIDEO_URL1 = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1d13-db76-7bfa-849f-02803d9e39fb/019c1d21-b686-7dee-b04e-97c8ffe99c28/video/스테이 머뭄.mp4"
|
||||||
|
|
||||||
|
VIDEO_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/019c1d13-db76-7bfa-849f-02803d9e39fb/019c1d21-b686-7dee-b04e-97c8ffe99c28/video/28aa6541ddd74c348c5aae730a232454.mp4"
|
||||||
|
|
||||||
|
VIDEO_CAPTION = "Test video from Instagram POC #test"
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""비디오 업로드 POC 실행"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Instagram Graph API - 비디오 업로드 POC")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
async with InstagramClient(access_token=ACCESS_TOKEN) as client:
|
||||||
|
# Step 1: 접속 테스트
|
||||||
|
print("\n[Step 1] 접속 테스트")
|
||||||
|
print("-" * 40)
|
||||||
|
try:
|
||||||
|
account_id = await client.get_account_id()
|
||||||
|
print("[성공] 접속 확인 완료")
|
||||||
|
print(f" Account ID: {account_id}")
|
||||||
|
except Exception as e:
|
||||||
|
error_state, message, extra_info = parse_instagram_error(e)
|
||||||
|
if error_state == ErrorState.AUTH_ERROR:
|
||||||
|
print(f"[실패] 인증 실패: {message}")
|
||||||
|
else:
|
||||||
|
print(f"[실패] 접속 실패: {message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: 비디오 업로드
|
||||||
|
print("\n[Step 2] 비디오 업로드")
|
||||||
|
print("-" * 40)
|
||||||
|
print(f" 비디오 URL: {VIDEO_URL}")
|
||||||
|
print(f" 캡션: {VIDEO_CAPTION}")
|
||||||
|
print("\n업로드 중... (비디오 처리에 시간이 걸릴 수 있습니다)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
media = await client.publish_video(
|
||||||
|
video_url=VIDEO_URL,
|
||||||
|
caption=VIDEO_CAPTION,
|
||||||
|
share_to_feed=True,
|
||||||
|
)
|
||||||
|
print("\n[성공] 비디오 업로드 완료!")
|
||||||
|
print(f" 미디어 ID: {media.id}")
|
||||||
|
print(f" 링크: {media.permalink}")
|
||||||
|
except Exception as e:
|
||||||
|
error_state, message, extra_info = parse_instagram_error(e)
|
||||||
|
if error_state == ErrorState.RATE_LIMIT:
|
||||||
|
retry_after = extra_info.get("retry_after", 60)
|
||||||
|
print(f"\n[실패] Rate Limit: {message} (재시도: {retry_after}초)")
|
||||||
|
elif error_state == ErrorState.CONTAINER_TIMEOUT:
|
||||||
|
print(f"\n[실패] 타임아웃: {message}")
|
||||||
|
elif error_state == ErrorState.CONTAINER_ERROR:
|
||||||
|
status = extra_info.get("status", "UNKNOWN")
|
||||||
|
print(f"\n[실패] 컨테이너 에러: {message} (상태: {status})")
|
||||||
|
else:
|
||||||
|
print(f"\n[실패] 업로드 실패: {message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 3: 업로드 확인
|
||||||
|
print("\n[Step 3] 업로드 확인")
|
||||||
|
print("-" * 40)
|
||||||
|
try:
|
||||||
|
verified_media = await client.get_media(media.id)
|
||||||
|
print("[성공] 업로드 확인 완료!")
|
||||||
|
print(f" 미디어 ID: {verified_media.id}")
|
||||||
|
print(f" 타입: {verified_media.media_type}")
|
||||||
|
print(f" URL: {verified_media.media_url}")
|
||||||
|
print(f" 퍼머링크: {verified_media.permalink}")
|
||||||
|
print(f" 게시일: {verified_media.timestamp}")
|
||||||
|
if verified_media.caption:
|
||||||
|
print(f" 캡션: {verified_media.caption}")
|
||||||
|
except Exception as e:
|
||||||
|
error_state, message, _ = parse_instagram_error(e)
|
||||||
|
print(f"[실패] 확인 실패: {message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("모든 단계 완료!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""
|
||||||
|
Instagram Graph API Pydantic 모델
|
||||||
|
|
||||||
|
API 응답 데이터를 위한 Pydantic 모델 정의입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Media(BaseModel):
|
||||||
|
"""Instagram 미디어 정보"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
media_type: Optional[str] = None
|
||||||
|
media_url: Optional[str] = None
|
||||||
|
thumbnail_url: Optional[str] = None
|
||||||
|
caption: Optional[str] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
permalink: Optional[str] = None
|
||||||
|
like_count: int = 0
|
||||||
|
comments_count: int = 0
|
||||||
|
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):
|
||||||
|
"""미디어 컨테이너 상태"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
status_code: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_finished(self) -> bool:
|
||||||
|
return self.status_code == "FINISHED"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_error(self) -> bool:
|
||||||
|
return self.status_code == "ERROR"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_in_progress(self) -> bool:
|
||||||
|
return self.status_code == "IN_PROGRESS"
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(BaseModel):
|
||||||
|
"""API 에러 응답"""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
type: Optional[str] = None
|
||||||
|
code: Optional[int] = None
|
||||||
|
error_subcode: Optional[int] = None
|
||||||
|
fbtrace_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""에러 응답 래퍼"""
|
||||||
|
|
||||||
|
error: APIError
|
||||||
|
|
@ -3,7 +3,7 @@ name = "o2o-castad-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14"
|
requires-python = "==3.13.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiohttp>=3.13.2",
|
"aiohttp>=3.13.2",
|
||||||
|
|
@ -15,6 +15,7 @@ dependencies = [
|
||||||
"openai>=2.13.0",
|
"openai>=2.13.0",
|
||||||
"playwright>=1.57.0",
|
"playwright>=1.57.0",
|
||||||
"pydantic-settings>=2.12.0",
|
"pydantic-settings>=2.12.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
"python-jose[cryptography]>=3.5.0",
|
"python-jose[cryptography]>=3.5.0",
|
||||||
"python-multipart>=0.0.21",
|
"python-multipart>=0.0.21",
|
||||||
"redis>=7.1.0",
|
"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
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.14"
|
requires-python = "==3.13.11"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiofiles"
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -130,6 +113,16 @@ name = "asyncmy"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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]]
|
[[package]]
|
||||||
name = "attrs"
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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/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/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/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/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/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" },
|
{ 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" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -425,38 +378,38 @@ version = "1.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -511,13 +457,13 @@ version = "0.7.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
|
|
@ -580,31 +526,24 @@ version = "0.12.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -625,28 +564,28 @@ version = "3.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
|
|
@ -664,42 +603,42 @@ version = "6.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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 = "openai" },
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
{ name = "python-jose", extra = ["cryptography"] },
|
{ name = "python-jose", extra = ["cryptography"] },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
|
|
@ -746,6 +686,7 @@ requires-dist = [
|
||||||
{ name = "openai", specifier = ">=2.13.0" },
|
{ name = "openai", specifier = ">=2.13.0" },
|
||||||
{ name = "playwright", specifier = ">=1.57.0" },
|
{ name = "playwright", specifier = ">=1.57.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.12.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-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.21" },
|
{ name = "python-multipart", specifier = ">=0.0.21" },
|
||||||
{ name = "redis", specifier = ">=7.1.0" },
|
{ name = "redis", specifier = ">=7.1.0" },
|
||||||
|
|
@ -824,36 +765,36 @@ version = "0.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1062,24 +989,16 @@ version = "6.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1124,36 +1043,21 @@ version = "0.7.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
|
|
@ -1464,24 +1362,15 @@ version = "16.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
{ 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" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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