added auth
parent
a3d3c75463
commit
a9d0a3ee7f
|
|
@ -0,0 +1,32 @@
|
|||
# 설계 에이전트 (Design Agent)
|
||||
|
||||
Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다.
|
||||
|
||||
## 역할
|
||||
- 사용자의 요구사항을 분석하고 설계 문서를 작성합니다
|
||||
- 기존 프로젝트 패턴과 일관성 있는 아키텍처를 설계합니다
|
||||
- API 엔드포인트, 데이터 모델, 서비스 레이어, 스키마를 설계합니다
|
||||
|
||||
## 수행 절차
|
||||
|
||||
### 1단계: 요구사항 분석
|
||||
- 사용자의 요구사항을 명확히 파악합니다
|
||||
- 기능적 요구사항과 비기능적 요구사항을 분리합니다
|
||||
|
||||
### 2단계: 관련 코드 검토
|
||||
- 프로젝트의 기존 구조와 패턴을 분석합니다
|
||||
- `app/` 디렉토리의 모듈 구조를 확인합니다
|
||||
|
||||
### 3단계: 설계 수행
|
||||
다음 원칙을 준수하여 설계합니다:
|
||||
- **레이어드 아키텍처**: Router → Service → Repository 패턴
|
||||
- **비동기 우선**: 모든 I/O 작업은 async/await 사용
|
||||
- **의존성 주입**: FastAPI의 Depends 활용
|
||||
|
||||
### 4단계: 설계 검수
|
||||
- 기존 프로젝트 패턴과 일관성 확인
|
||||
- N+1 쿼리 문제 검토
|
||||
- SOLID 원칙 준수 여부 확인
|
||||
|
||||
## 출력
|
||||
설계 문서를 화면에 출력합니다.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# 개발 에이전트 (Development Agent)
|
||||
|
||||
Python과 FastAPI 전문 개발자로서, 비동기 프로그래밍과 디자인 패턴에 대한 전문적인 지식을 보유하고 있습니다.
|
||||
|
||||
## 역할
|
||||
- 설계 문서를 바탕으로 코드를 구현합니다
|
||||
- 프로젝트 컨벤션을 준수하여 개발합니다
|
||||
- 비동기 처리 패턴과 예외 처리를 적용합니다
|
||||
|
||||
## 코딩 표준
|
||||
|
||||
### Docstring
|
||||
```python
|
||||
async def create_user(self, user_data: UserCreate) -> User:
|
||||
"""
|
||||
새로운 사용자를 생성합니다.
|
||||
|
||||
Args:
|
||||
user_data: 사용자 생성 데이터
|
||||
|
||||
Returns:
|
||||
생성된 User 객체
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 로깅
|
||||
```python
|
||||
from app.core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
logger.debug(f"[1/3] 작업 시작: id={id}")
|
||||
```
|
||||
|
||||
### 비동기 병렬 처리
|
||||
```python
|
||||
import asyncio
|
||||
user, orders, stats = await asyncio.gather(
|
||||
user_task, orders_task, stats_task
|
||||
)
|
||||
```
|
||||
|
||||
## 구현 순서
|
||||
1. 모델 (models.py)
|
||||
2. 스키마 (schemas/)
|
||||
3. 서비스 (services/)
|
||||
4. 라우터 (api/routers/)
|
||||
5. 의존성 (dependencies.py)
|
||||
|
||||
## 검수 항목
|
||||
- import 문이 올바른가?
|
||||
- 타입 힌트가 정확한가?
|
||||
- 비동기 함수에 await가 누락되지 않았는가?
|
||||
- 순환 참조가 발생하지 않는가?
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# 코드리뷰 에이전트 (Code Review Agent)
|
||||
|
||||
Python과 FastAPI 전문 개발자로서, 수정된 파일들을 엔드포인트부터 흐름을 추적하여 문제점을 분석하고 개선사항을 리포트합니다.
|
||||
|
||||
**중요**: 이 에이전트는 파일을 수정하거나 생성하지 않습니다. 오직 분석 결과를 화면에 출력합니다.
|
||||
|
||||
## 역할
|
||||
- 변경된 코드의 전체 흐름을 추적합니다
|
||||
- 보안, 성능, 코드 품질을 검사합니다
|
||||
- 개선사항을 도출하여 리포트합니다
|
||||
|
||||
## 흐름 추적
|
||||
```
|
||||
Request → Router → Dependency → Service → Repository → Database
|
||||
↓
|
||||
Response ← Router ← Service ← Repository ←
|
||||
```
|
||||
|
||||
## 검사 항목
|
||||
|
||||
### 보안 검사
|
||||
- SQL Injection 취약점
|
||||
- XSS 취약점
|
||||
- 인증/인가 누락
|
||||
- 민감 정보 노출
|
||||
|
||||
### 성능 검사
|
||||
- N+1 쿼리 문제
|
||||
- 불필요한 DB 호출
|
||||
- 비동기 처리 누락
|
||||
- 캐싱 가능 여부
|
||||
|
||||
### 코드 품질 검사
|
||||
- 타입 힌트 정확성
|
||||
- 예외 처리 적절성
|
||||
- 로깅 충분성
|
||||
- SOLID 원칙 준수
|
||||
|
||||
## 심각도 정의
|
||||
- 🔴 Critical: 보안 취약점, 데이터 손실 가능성
|
||||
- 🟡 Warning: 성능 저하, 유지보수성 저하
|
||||
- 🟢 Info: 코드 스타일, 베스트 프랙티스 권장
|
||||
|
||||
## 출력
|
||||
코드 리뷰 리포트를 화면에 출력합니다.
|
||||
|
|
@ -13,11 +13,11 @@ Python FastAPI 기반의 O2O Castad 백엔드 서비스
|
|||
## 프로젝트 구조
|
||||
```
|
||||
app/
|
||||
├── auth/ # 인증 모듈
|
||||
├── core/ # 핵심 유틸리티 (logging, exceptions)
|
||||
├── database/ # DB 세션 및 Redis 설정
|
||||
├── dependencies/ # FastAPI 의존성 주입
|
||||
├── home/ # 홈 모듈
|
||||
├── home/ # 홈 모듈 (크롤링, 이미지 업로드)
|
||||
├── user/ # 사용자 모듈 (카카오 로그인, JWT 인증)
|
||||
├── lyric/ # 가사 모듈
|
||||
├── song/ # 노래 모듈
|
||||
├── video/ # 비디오 모듈
|
||||
|
|
|
|||
|
|
@ -1,15 +1,3 @@
|
|||
"""API 1 Version Router Module."""
|
||||
|
||||
# from fastapi import APIRouter, Depends
|
||||
|
||||
# API 버전 1 라우터를 정의합니다.
|
||||
# router = APIRouter(
|
||||
# prefix="/api/v1",
|
||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||
# )
|
||||
# router = APIRouter(
|
||||
# prefix="/api/v1",
|
||||
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
|
||||
# )
|
||||
# router.include_router(auth.router, tags=[Tags.AUTH])
|
||||
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
|
||||
"""
|
||||
Home API v1 라우터 모듈
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ KOREAN_CITIES = [
|
|||
]
|
||||
# fmt: on
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["Home"])
|
||||
|
||||
|
||||
def _extract_region_from_address(road_address: str | None) -> str:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Lyric API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -47,7 +47,7 @@ from app.utils.pagination import PaginatedResponse, get_paginated
|
|||
# 로거 설정
|
||||
logger = get_logger("lyric")
|
||||
|
||||
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
||||
router = APIRouter(prefix="/lyric", tags=["Lyric"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Song API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -38,7 +38,7 @@ from app.utils.suno import SunoService
|
|||
|
||||
logger = get_logger("song")
|
||||
|
||||
router = APIRouter(prefix="/song", tags=["song"])
|
||||
router = APIRouter(prefix="/song", tags=["Song"])
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
User API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"""
|
||||
인증 API 라우터
|
||||
|
||||
카카오 로그인, 토큰 갱신, 로그아웃, 내 정보 조회 엔드포인트를 제공합니다.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Request, status
|
||||
from fastapi.responses import Response
|
||||
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.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoCallbackRequest,
|
||||
KakaoLoginResponse,
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
UserResponse,
|
||||
)
|
||||
from app.user.services import auth_service, kakao_client
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/kakao/login",
|
||||
response_model=KakaoLoginResponse,
|
||||
summary="카카오 로그인 URL 요청",
|
||||
description="카카오 OAuth 인증 페이지 URL을 반환합니다.",
|
||||
)
|
||||
async def kakao_login() -> KakaoLoginResponse:
|
||||
"""
|
||||
카카오 로그인 URL 반환
|
||||
|
||||
프론트엔드에서 이 URL로 사용자를 리다이렉트하면
|
||||
카카오 로그인 페이지가 표시됩니다.
|
||||
"""
|
||||
auth_url = kakao_client.get_authorization_url()
|
||||
return KakaoLoginResponse(auth_url=auth_url)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/kakao/callback",
|
||||
response_model=LoginResponse,
|
||||
summary="카카오 로그인 콜백",
|
||||
description="카카오 인가 코드를 받아 로그인/가입을 처리하고 JWT 토큰을 발급합니다.",
|
||||
)
|
||||
async def kakao_callback(
|
||||
request: Request,
|
||||
body: KakaoCallbackRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user_agent: Optional[str] = Header(None, alias="User-Agent"),
|
||||
) -> LoginResponse:
|
||||
"""
|
||||
카카오 로그인 콜백
|
||||
|
||||
카카오 로그인 성공 후 발급받은 인가 코드로
|
||||
JWT 토큰을 발급합니다.
|
||||
|
||||
신규 사용자인 경우 자동으로 회원가입이 처리됩니다.
|
||||
"""
|
||||
# 클라이언트 IP 추출
|
||||
ip_address = request.client.host if request.client else None
|
||||
|
||||
# X-Forwarded-For 헤더 확인 (프록시/로드밸런서 뒤에 있는 경우)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
ip_address = forwarded_for.split(",")[0].strip()
|
||||
|
||||
return await auth_service.kakao_login(
|
||||
code=body.code,
|
||||
session=session,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=AccessTokenResponse,
|
||||
summary="토큰 갱신",
|
||||
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
|
||||
)
|
||||
async def refresh_token(
|
||||
body: RefreshTokenRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AccessTokenResponse:
|
||||
"""
|
||||
액세스 토큰 갱신
|
||||
|
||||
유효한 리프레시 토큰을 제출하면 새 액세스 토큰을 발급합니다.
|
||||
리프레시 토큰은 변경되지 않습니다.
|
||||
"""
|
||||
return await auth_service.refresh_tokens(
|
||||
refresh_token=body.refresh_token,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="로그아웃",
|
||||
description="현재 세션의 리프레시 토큰을 폐기합니다.",
|
||||
)
|
||||
async def logout(
|
||||
body: RefreshTokenRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""
|
||||
로그아웃
|
||||
|
||||
현재 사용 중인 리프레시 토큰을 폐기합니다.
|
||||
해당 토큰으로는 더 이상 액세스 토큰을 갱신할 수 없습니다.
|
||||
"""
|
||||
await auth_service.logout(
|
||||
user_id=current_user.id,
|
||||
refresh_token=body.refresh_token,
|
||||
session=session,
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout/all",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="모든 기기에서 로그아웃",
|
||||
description="사용자의 모든 리프레시 토큰을 폐기합니다.",
|
||||
)
|
||||
async def logout_all(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""
|
||||
모든 기기에서 로그아웃
|
||||
|
||||
사용자의 모든 리프레시 토큰을 폐기합니다.
|
||||
모든 기기에서 재로그인이 필요합니다.
|
||||
"""
|
||||
await auth_service.logout_all(
|
||||
user_id=current_user.id,
|
||||
session=session,
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserResponse,
|
||||
summary="내 정보 조회",
|
||||
description="현재 로그인한 사용자의 정보를 반환합니다.",
|
||||
)
|
||||
async def get_me(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
내 정보 조회
|
||||
|
||||
현재 로그인한 사용자의 상세 정보를 반환합니다.
|
||||
"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"""
|
||||
User 의존성 주입 모듈
|
||||
"""
|
||||
|
||||
from app.user.dependencies.auth import (
|
||||
get_current_user,
|
||||
get_current_user_optional,
|
||||
get_current_admin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_current_user",
|
||||
"get_current_user_optional",
|
||||
"get_current_admin",
|
||||
]
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
"""
|
||||
인증 의존성 주입
|
||||
|
||||
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.user.exceptions import (
|
||||
AdminRequiredError,
|
||||
InvalidTokenError,
|
||||
MissingTokenError,
|
||||
TokenExpiredError,
|
||||
UserInactiveError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from app.user.models import User
|
||||
from app.user.services.jwt import decode_token
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
"""
|
||||
현재 로그인한 사용자 반환 (필수 인증)
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer 토큰
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
User: 현재 로그인한 사용자
|
||||
|
||||
Raises:
|
||||
MissingTokenError: 토큰이 없는 경우
|
||||
InvalidTokenError: 토큰이 유효하지 않은 경우
|
||||
TokenExpiredError: 토큰이 만료된 경우
|
||||
UserNotFoundError: 사용자를 찾을 수 없는 경우
|
||||
UserInactiveError: 비활성화된 계정인 경우
|
||||
"""
|
||||
if credentials is None:
|
||||
raise MissingTokenError()
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get("type") != "access":
|
||||
raise InvalidTokenError("액세스 토큰이 아닙니다.")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# 사용자 조회
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.id == int(user_id),
|
||||
User.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise UserNotFoundError()
|
||||
|
||||
if not user.is_active:
|
||||
raise UserInactiveError()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
현재 로그인한 사용자 반환 (선택적 인증)
|
||||
|
||||
토큰이 없거나 유효하지 않으면 None 반환
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer 토큰
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
User | None: 로그인한 사용자 또는 None
|
||||
"""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
if payload.get("type") != "access":
|
||||
return None
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(
|
||||
User.id == int(user_id),
|
||||
User.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""
|
||||
현재 로그인한 관리자 반환
|
||||
|
||||
Args:
|
||||
current_user: 현재 로그인한 사용자
|
||||
|
||||
Returns:
|
||||
User: 관리자 권한이 있는 사용자
|
||||
|
||||
Raises:
|
||||
AdminRequiredError: 관리자 권한이 없는 경우
|
||||
"""
|
||||
if not current_user.is_admin and current_user.role != "admin":
|
||||
raise AdminRequiredError()
|
||||
|
||||
return current_user
|
||||
|
|
@ -7,7 +7,7 @@ User 모듈 SQLAlchemy 모델 정의
|
|||
from datetime import date, datetime
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Index, String, func
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database.session import Base
|
||||
|
|
@ -234,6 +234,18 @@ class User(Base):
|
|||
lazy="selectin",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# RefreshToken 1:N 관계
|
||||
# ==========================================================================
|
||||
# 한 사용자는 여러 리프레시 토큰을 가질 수 있음 (다중 기기 로그인)
|
||||
# ==========================================================================
|
||||
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
|
||||
"RefreshToken",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<User("
|
||||
|
|
@ -245,3 +257,119 @@ class User(Base):
|
|||
f"is_deleted={self.is_deleted}"
|
||||
f")>"
|
||||
)
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
"""
|
||||
리프레시 토큰 테이블
|
||||
|
||||
JWT 리프레시 토큰을 DB에 저장하여 관리합니다.
|
||||
토큰 폐기(로그아웃), 다중 기기 로그인 관리 등에 활용됩니다.
|
||||
|
||||
Attributes:
|
||||
id: 고유 식별자 (자동 증가)
|
||||
user_id: 사용자 외래키 (User.id 참조)
|
||||
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
|
||||
expires_at: 토큰 만료 일시
|
||||
is_revoked: 토큰 폐기 여부 (로그아웃 시 True)
|
||||
created_at: 토큰 생성 일시
|
||||
revoked_at: 토큰 폐기 일시
|
||||
user_agent: 접속 기기 정보 (브라우저, OS 등)
|
||||
ip_address: 접속 IP 주소
|
||||
|
||||
보안 고려사항:
|
||||
- 토큰 원본은 저장하지 않고 해시값만 저장
|
||||
- 토큰 검증 시 해시값 비교로 유효성 확인
|
||||
- 로그아웃 시 is_revoked=True로 설정하여 재사용 방지
|
||||
"""
|
||||
|
||||
__tablename__ = "refresh_token"
|
||||
__table_args__ = (
|
||||
Index("idx_refresh_token_user_id", "user_id"),
|
||||
Index("idx_refresh_token_token_hash", "token_hash", unique=True),
|
||||
Index("idx_refresh_token_expires_at", "expires_at"),
|
||||
Index("idx_refresh_token_is_revoked", "is_revoked"),
|
||||
{
|
||||
"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_id: Mapped[int] = mapped_column(
|
||||
BigInteger,
|
||||
ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="사용자 외래키 (User.id 참조)",
|
||||
)
|
||||
|
||||
token_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="리프레시 토큰 SHA-256 해시값",
|
||||
)
|
||||
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
comment="토큰 만료 일시",
|
||||
)
|
||||
|
||||
is_revoked: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="토큰 폐기 여부 (True: 폐기됨)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="토큰 생성 일시",
|
||||
)
|
||||
|
||||
revoked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="토큰 폐기 일시",
|
||||
)
|
||||
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
comment="접속 기기 정보 (브라우저, OS 등)",
|
||||
)
|
||||
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(
|
||||
String(45),
|
||||
nullable=True,
|
||||
comment="접속 IP 주소 (IPv4/IPv6)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# User 관계
|
||||
# ==========================================================================
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="refresh_tokens",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<RefreshToken("
|
||||
f"id={self.id}, "
|
||||
f"user_id={self.user_id}, "
|
||||
f"is_revoked={self.is_revoked}, "
|
||||
f"expires_at={self.expires_at}"
|
||||
f")>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,12 +18,28 @@ class KakaoLoginResponse(BaseModel):
|
|||
|
||||
auth_url: str = Field(..., description="카카오 인증 페이지 URL")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/api/v1/user/auth/kakao/callback&response_type=code"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class KakaoCallbackRequest(BaseModel):
|
||||
"""카카오 콜백 요청 (인가 코드)"""
|
||||
|
||||
code: str = Field(..., min_length=1, description="카카오 인가 코드")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"code": "AUTHORIZATION_CODE_FROM_KAKAO"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JWT 토큰 스키마
|
||||
|
|
@ -36,6 +52,17 @@ class TokenResponse(BaseModel):
|
|||
token_type: str = Field(default="Bearer", description="토큰 타입")
|
||||
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AccessTokenResponse(BaseModel):
|
||||
"""액세스 토큰 갱신 응답"""
|
||||
|
|
@ -44,12 +71,30 @@ class AccessTokenResponse(BaseModel):
|
|||
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):
|
||||
"""토큰 갱신 요청"""
|
||||
|
||||
refresh_token: str = Field(..., min_length=1, description="리프레시 토큰")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 사용자 정보 스키마
|
||||
|
|
@ -68,7 +113,23 @@ class UserResponse(BaseModel):
|
|||
last_login_at: Optional[datetime] = Field(None, description="마지막 로그인 일시")
|
||||
created_at: datetime = Field(..., description="가입 일시")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
model_config = {
|
||||
"from_attributes": True,
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"id": 1,
|
||||
"kakao_id": 1234567890,
|
||||
"email": "user@kakao.com",
|
||||
"nickname": "홍길동",
|
||||
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
|
||||
"thumbnail_image_url": "https://k.kakaocdn.net/dn/.../thumb.jpg",
|
||||
"is_active": True,
|
||||
"is_admin": False,
|
||||
"last_login_at": "2026-01-15T10:30:00",
|
||||
"created_at": "2026-01-01T09:00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UserBriefResponse(BaseModel):
|
||||
|
|
@ -80,7 +141,18 @@ class UserBriefResponse(BaseModel):
|
|||
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
||||
is_new_user: bool = Field(..., description="신규 가입 여부")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
model_config = {
|
||||
"from_attributes": True,
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"id": 1,
|
||||
"nickname": "홍길동",
|
||||
"email": "user@kakao.com",
|
||||
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
|
||||
"is_new_user": False
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
|
|
@ -92,6 +164,24 @@ class LoginResponse(BaseModel):
|
|||
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
|
||||
user: UserBriefResponse = Field(..., description="사용자 정보")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"nickname": "홍길동",
|
||||
"email": "user@kakao.com",
|
||||
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
|
||||
"is_new_user": False
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 내부 사용 스키마 (카카오 API 응답 파싱)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
User 서비스 모듈
|
||||
|
||||
인증 및 사용자 관련 비즈니스 로직을 제공합니다.
|
||||
"""
|
||||
|
||||
from app.user.services.auth import AuthService, auth_service
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_access_token_expire_seconds,
|
||||
get_refresh_token_expires_at,
|
||||
get_token_hash,
|
||||
)
|
||||
from app.user.services.kakao import KakaoOAuthClient, kakao_client
|
||||
|
||||
__all__ = [
|
||||
"AuthService",
|
||||
"auth_service",
|
||||
"KakaoOAuthClient",
|
||||
"kakao_client",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
"get_token_hash",
|
||||
"get_refresh_token_expires_at",
|
||||
"get_access_token_expire_seconds",
|
||||
]
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
"""
|
||||
인증 서비스
|
||||
|
||||
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.user.exceptions import (
|
||||
InvalidTokenError,
|
||||
TokenExpiredError,
|
||||
TokenRevokedError,
|
||||
UserInactiveError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from app.user.models import RefreshToken, User
|
||||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoUserInfo,
|
||||
LoginResponse,
|
||||
UserBriefResponse,
|
||||
)
|
||||
from app.user.services.jwt import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_access_token_expire_seconds,
|
||||
get_refresh_token_expires_at,
|
||||
get_token_hash,
|
||||
)
|
||||
from app.user.services.kakao import kakao_client
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
인증 서비스
|
||||
|
||||
카카오 로그인, JWT 토큰 발급/갱신/폐기, 사용자 관리 기능을 제공합니다.
|
||||
"""
|
||||
|
||||
async def kakao_login(
|
||||
self,
|
||||
code: str,
|
||||
session: AsyncSession,
|
||||
user_agent: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> LoginResponse:
|
||||
"""
|
||||
카카오 로그인 처리
|
||||
|
||||
1. 카카오 인가 코드로 액세스 토큰 획득
|
||||
2. 카카오 사용자 정보 조회
|
||||
3. 사용자 조회 또는 생성
|
||||
4. JWT 토큰 발급 및 리프레시 토큰 저장
|
||||
|
||||
Args:
|
||||
code: 카카오 인가 코드
|
||||
session: DB 세션
|
||||
user_agent: 클라이언트 User-Agent
|
||||
ip_address: 클라이언트 IP 주소
|
||||
|
||||
Returns:
|
||||
LoginResponse: 토큰 및 사용자 정보
|
||||
"""
|
||||
# 1. 카카오 토큰 획득
|
||||
kakao_token = await kakao_client.get_access_token(code)
|
||||
|
||||
# 2. 카카오 사용자 정보 조회
|
||||
kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token)
|
||||
|
||||
# 3. 사용자 조회 또는 생성
|
||||
user, is_new_user = await self._get_or_create_user(kakao_user_info, session)
|
||||
|
||||
# 4. 비활성화 계정 체크
|
||||
if not user.is_active:
|
||||
raise UserInactiveError()
|
||||
|
||||
# 5. JWT 토큰 생성
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
# 6. 리프레시 토큰 DB 저장
|
||||
await self._save_refresh_token(
|
||||
user_id=user.id,
|
||||
token=refresh_token,
|
||||
session=session,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
# 7. 마지막 로그인 시간 업데이트
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=get_access_token_expire_seconds(),
|
||||
user=UserBriefResponse(
|
||||
id=user.id,
|
||||
nickname=user.nickname,
|
||||
email=user.email,
|
||||
profile_image_url=user.profile_image_url,
|
||||
is_new_user=is_new_user,
|
||||
),
|
||||
)
|
||||
|
||||
async def refresh_tokens(
|
||||
self,
|
||||
refresh_token: str,
|
||||
session: AsyncSession,
|
||||
) -> AccessTokenResponse:
|
||||
"""
|
||||
리프레시 토큰으로 액세스 토큰 갱신
|
||||
|
||||
Args:
|
||||
refresh_token: 리프레시 토큰
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
AccessTokenResponse: 새 액세스 토큰
|
||||
|
||||
Raises:
|
||||
InvalidTokenError: 토큰이 유효하지 않은 경우
|
||||
TokenExpiredError: 토큰이 만료된 경우
|
||||
TokenRevokedError: 토큰이 폐기된 경우
|
||||
"""
|
||||
# 1. 토큰 디코딩 및 검증
|
||||
payload = decode_token(refresh_token)
|
||||
if payload is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
|
||||
|
||||
# 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:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# 3. 토큰 상태 확인
|
||||
if db_token.is_revoked:
|
||||
raise TokenRevokedError()
|
||||
|
||||
if db_token.expires_at < datetime.now(timezone.utc):
|
||||
raise TokenExpiredError()
|
||||
|
||||
# 4. 사용자 확인
|
||||
user_id = int(payload.get("sub"))
|
||||
user = await self._get_user_by_id(user_id, session)
|
||||
|
||||
if user is None:
|
||||
raise UserNotFoundError()
|
||||
|
||||
if not user.is_active:
|
||||
raise UserInactiveError()
|
||||
|
||||
# 5. 새 액세스 토큰 발급
|
||||
new_access_token = create_access_token(user.id)
|
||||
|
||||
return AccessTokenResponse(
|
||||
access_token=new_access_token,
|
||||
token_type="Bearer",
|
||||
expires_in=get_access_token_expire_seconds(),
|
||||
)
|
||||
|
||||
async def logout(
|
||||
self,
|
||||
user_id: int,
|
||||
refresh_token: str,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
로그아웃 (리프레시 토큰 폐기)
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
refresh_token: 폐기할 리프레시 토큰
|
||||
session: DB 세션
|
||||
"""
|
||||
token_hash = get_token_hash(refresh_token)
|
||||
await self._revoke_refresh_token_by_hash(token_hash, session)
|
||||
|
||||
async def logout_all(
|
||||
self,
|
||||
user_id: int,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
모든 기기에서 로그아웃 (사용자의 모든 리프레시 토큰 폐기)
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
session: DB 세션
|
||||
"""
|
||||
await self._revoke_all_user_tokens(user_id, session)
|
||||
|
||||
async def _get_or_create_user(
|
||||
self,
|
||||
kakao_user_info: KakaoUserInfo,
|
||||
session: AsyncSession,
|
||||
) -> tuple[User, bool]:
|
||||
"""
|
||||
사용자 조회 또는 생성
|
||||
|
||||
Args:
|
||||
kakao_user_info: 카카오 사용자 정보
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
(User, is_new_user) 튜플
|
||||
"""
|
||||
kakao_id = kakao_user_info.id
|
||||
kakao_account = kakao_user_info.kakao_account
|
||||
profile = kakao_account.profile if kakao_account else None
|
||||
|
||||
# 기존 사용자 조회
|
||||
result = await session.execute(
|
||||
select(User).where(User.kakao_id == kakao_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is not None:
|
||||
# 기존 사용자: 프로필 정보 업데이트
|
||||
if profile:
|
||||
user.nickname = profile.nickname
|
||||
user.profile_image_url = profile.profile_image_url
|
||||
user.thumbnail_image_url = profile.thumbnail_image_url
|
||||
if kakao_account and kakao_account.email:
|
||||
user.email = kakao_account.email
|
||||
await session.flush()
|
||||
return user, False
|
||||
|
||||
# 신규 사용자 생성
|
||||
new_user = User(
|
||||
kakao_id=kakao_id,
|
||||
email=kakao_account.email if kakao_account else None,
|
||||
nickname=profile.nickname if profile else None,
|
||||
profile_image_url=profile.profile_image_url if profile else None,
|
||||
thumbnail_image_url=profile.thumbnail_image_url if profile else None,
|
||||
)
|
||||
session.add(new_user)
|
||||
await session.flush()
|
||||
await session.refresh(new_user)
|
||||
return new_user, True
|
||||
|
||||
async def _save_refresh_token(
|
||||
self,
|
||||
user_id: int,
|
||||
token: str,
|
||||
session: AsyncSession,
|
||||
user_agent: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> RefreshToken:
|
||||
"""
|
||||
리프레시 토큰 DB 저장
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
token: 리프레시 토큰
|
||||
session: DB 세션
|
||||
user_agent: User-Agent
|
||||
ip_address: IP 주소
|
||||
|
||||
Returns:
|
||||
저장된 RefreshToken 객체
|
||||
"""
|
||||
token_hash = get_token_hash(token)
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
session.add(refresh_token)
|
||||
await session.flush()
|
||||
return refresh_token
|
||||
|
||||
async def _get_refresh_token_by_hash(
|
||||
self,
|
||||
token_hash: str,
|
||||
session: AsyncSession,
|
||||
) -> Optional[RefreshToken]:
|
||||
"""
|
||||
해시값으로 리프레시 토큰 조회
|
||||
|
||||
Args:
|
||||
token_hash: 토큰 해시값
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
RefreshToken 객체 또는 None
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_user_by_id(
|
||||
self,
|
||||
user_id: int,
|
||||
session: AsyncSession,
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
ID로 사용자 조회
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
session: DB 세션
|
||||
|
||||
Returns:
|
||||
User 객체 또는 None
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.id == user_id, User.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _revoke_refresh_token_by_hash(
|
||||
self,
|
||||
token_hash: str,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
해시값으로 리프레시 토큰 폐기
|
||||
|
||||
Args:
|
||||
token_hash: 토큰 해시값
|
||||
session: DB 세션
|
||||
"""
|
||||
await session.execute(
|
||||
update(RefreshToken)
|
||||
.where(RefreshToken.token_hash == token_hash)
|
||||
.values(
|
||||
is_revoked=True,
|
||||
revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _revoke_all_user_tokens(
|
||||
self,
|
||||
user_id: int,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
사용자의 모든 리프레시 토큰 폐기
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
session: DB 세션
|
||||
"""
|
||||
await session.execute(
|
||||
update(RefreshToken)
|
||||
.where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.is_revoked == False, # noqa: E712
|
||||
)
|
||||
.values(
|
||||
is_revoked=True,
|
||||
revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
auth_service = AuthService()
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
"""
|
||||
JWT 토큰 유틸리티
|
||||
|
||||
Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니다.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from config import jwt_settings
|
||||
|
||||
|
||||
def create_access_token(user_id: int) -> str:
|
||||
"""
|
||||
JWT 액세스 토큰 생성
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
JWT 액세스 토큰 문자열
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode = {
|
||||
"sub": str(user_id),
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
jwt_settings.JWT_SECRET,
|
||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
"""
|
||||
JWT 리프레시 토큰 생성
|
||||
|
||||
Args:
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
JWT 리프레시 토큰 문자열
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode = {
|
||||
"sub": str(user_id),
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
jwt_settings.JWT_SECRET,
|
||||
algorithm=jwt_settings.JWT_ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
JWT 토큰 디코딩
|
||||
|
||||
Args:
|
||||
token: JWT 토큰 문자열
|
||||
|
||||
Returns:
|
||||
디코딩된 페이로드 딕셔너리, 실패 시 None
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
jwt_settings.JWT_SECRET,
|
||||
algorithms=[jwt_settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def get_token_hash(token: str) -> str:
|
||||
"""
|
||||
토큰의 SHA-256 해시값 생성
|
||||
|
||||
리프레시 토큰을 DB에 저장할 때 원본 대신 해시값을 저장합니다.
|
||||
|
||||
Args:
|
||||
token: 해시할 토큰 문자열
|
||||
|
||||
Returns:
|
||||
토큰의 SHA-256 해시값 (64자 hex 문자열)
|
||||
"""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_refresh_token_expires_at() -> datetime:
|
||||
"""
|
||||
리프레시 토큰 만료 시간 계산
|
||||
|
||||
Returns:
|
||||
리프레시 토큰 만료 datetime (UTC)
|
||||
"""
|
||||
return datetime.now(timezone.utc) + timedelta(
|
||||
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
|
||||
def get_access_token_expire_seconds() -> int:
|
||||
"""
|
||||
액세스 토큰 만료 시간(초) 반환
|
||||
|
||||
Returns:
|
||||
액세스 토큰 만료 시간 (초)
|
||||
"""
|
||||
return jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
"""
|
||||
카카오 OAuth API 클라이언트
|
||||
|
||||
카카오 로그인 인증 흐름을 처리하는 클라이언트입니다.
|
||||
"""
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config import kakao_settings
|
||||
|
||||
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
|
||||
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
|
||||
|
||||
|
||||
class KakaoOAuthClient:
|
||||
"""
|
||||
카카오 OAuth API 클라이언트
|
||||
|
||||
카카오 로그인 인증 흐름:
|
||||
1. get_authorization_url()로 카카오 로그인 페이지 URL 획득
|
||||
2. 사용자가 카카오에서 로그인 후 인가 코드(code) 발급
|
||||
3. get_access_token()으로 인가 코드를 액세스 토큰으로 교환
|
||||
4. get_user_info()로 사용자 정보 조회
|
||||
"""
|
||||
|
||||
AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
|
||||
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
|
||||
USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.client_id = kakao_settings.KAKAO_CLIENT_ID
|
||||
self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET
|
||||
self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI
|
||||
|
||||
def get_authorization_url(self) -> str:
|
||||
"""
|
||||
카카오 로그인 페이지 URL 반환
|
||||
|
||||
Returns:
|
||||
카카오 OAuth 인증 페이지 URL
|
||||
"""
|
||||
return (
|
||||
f"{self.AUTH_URL}"
|
||||
f"?client_id={self.client_id}"
|
||||
f"&redirect_uri={self.redirect_uri}"
|
||||
f"&response_type=code"
|
||||
)
|
||||
|
||||
async def get_access_token(self, code: str) -> KakaoTokenResponse:
|
||||
"""
|
||||
인가 코드로 액세스 토큰 획득
|
||||
|
||||
Args:
|
||||
code: 카카오 로그인 후 발급받은 인가 코드
|
||||
|
||||
Returns:
|
||||
KakaoTokenResponse: 카카오 토큰 정보
|
||||
|
||||
Raises:
|
||||
KakaoAuthFailedError: 토큰 발급 실패 시
|
||||
KakaoAPIError: API 호출 오류 시
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code": code,
|
||||
}
|
||||
|
||||
if self.client_secret:
|
||||
data["client_secret"] = self.client_secret
|
||||
|
||||
async with session.post(self.TOKEN_URL, data=data) as response:
|
||||
result = await response.json()
|
||||
|
||||
if "error" in result:
|
||||
error_desc = result.get(
|
||||
"error_description", result.get("error", "알 수 없는 오류")
|
||||
)
|
||||
raise KakaoAuthFailedError(f"카카오 토큰 발급 실패: {error_desc}")
|
||||
|
||||
return KakaoTokenResponse(**result)
|
||||
|
||||
except KakaoAuthFailedError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")
|
||||
|
||||
async def get_user_info(self, access_token: str) -> KakaoUserInfo:
|
||||
"""
|
||||
액세스 토큰으로 사용자 정보 조회
|
||||
|
||||
Args:
|
||||
access_token: 카카오 액세스 토큰
|
||||
|
||||
Returns:
|
||||
KakaoUserInfo: 카카오 사용자 정보
|
||||
|
||||
Raises:
|
||||
KakaoAuthFailedError: 사용자 정보 조회 실패 시
|
||||
KakaoAPIError: API 호출 오류 시
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
async with session.get(self.USER_INFO_URL, headers=headers) as response:
|
||||
result = await response.json()
|
||||
|
||||
if "id" not in result:
|
||||
raise KakaoAuthFailedError("카카오 사용자 정보를 가져올 수 없습니다.")
|
||||
|
||||
return KakaoUserInfo(**result)
|
||||
|
||||
except KakaoAuthFailedError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")
|
||||
|
||||
|
||||
kakao_client = KakaoOAuthClient()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Video API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -43,7 +43,7 @@ from app.utils.logger import get_logger
|
|||
|
||||
logger = get_logger("video")
|
||||
|
||||
router = APIRouter(prefix="/video", tags=["video"])
|
||||
router = APIRouter(prefix="/video", tags=["Video"])
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
33
config.py
33
config.py
|
|
@ -146,6 +146,37 @@ class CreatomateSettings(BaseSettings):
|
|||
model_config = _base_config
|
||||
|
||||
|
||||
class KakaoSettings(BaseSettings):
|
||||
"""카카오 OAuth 설정"""
|
||||
|
||||
KAKAO_CLIENT_ID: str = Field(default="", description="카카오 REST API 키")
|
||||
KAKAO_CLIENT_SECRET: str = Field(default="", description="카카오 Client Secret (선택)")
|
||||
KAKAO_REDIRECT_URI: str = Field(
|
||||
default="http://localhost:8000/api/v1/user/auth/kakao/callback",
|
||||
description="카카오 로그인 후 리다이렉트 URI",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class JWTSettings(BaseSettings):
|
||||
"""JWT 토큰 설정"""
|
||||
|
||||
JWT_SECRET: str = Field(
|
||||
default="your-super-secret-key-must-be-at-least-32-characters-long",
|
||||
description="JWT 서명 비밀키 (최소 32자)",
|
||||
)
|
||||
JWT_ALGORITHM: str = Field(default="HS256", description="JWT 알고리즘")
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(
|
||||
default=60, description="액세스 토큰 만료 시간 (분)"
|
||||
)
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(
|
||||
default=7, description="리프레시 토큰 만료 시간 (일)"
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class LogSettings(BaseSettings):
|
||||
"""
|
||||
로깅 설정 클래스
|
||||
|
|
@ -345,3 +376,5 @@ crawler_settings = CrawlerSettings()
|
|||
azure_blob_settings = AzureBlobSettings()
|
||||
creatomate_settings = CreatomateSettings()
|
||||
log_settings = LogSettings()
|
||||
kakao_settings = KakaoSettings()
|
||||
jwt_settings = JWTSettings()
|
||||
|
|
|
|||
|
|
@ -76,11 +76,11 @@ CastAD는 카카오 소셜 로그인만 지원하며, 인증 후 자체 JWT 토
|
|||
|
||||
| 단계 | 설명 | 관련 API |
|
||||
|------|------|----------|
|
||||
| 1-2 | 클라이언트가 로그인 요청, 백엔드가 카카오 인증 URL 생성 | `GET /api/v1/auth/kakao/login` |
|
||||
| 1-2 | 클라이언트가 로그인 요청, 백엔드가 카카오 인증 URL 생성 | `GET /user/auth/kakao/login` |
|
||||
| 3-4 | 사용자가 카카오에서 로그인, 인가 코드(code) 발급 | 카카오 OAuth |
|
||||
| 5-9 | 백엔드가 code로 카카오 토큰/사용자정보 획득 | 카카오 API |
|
||||
| 10-11 | DB에서 회원 조회, 없으면 신규 가입 | 내부 처리 |
|
||||
| 12 | 자체 JWT 토큰 발급 후 클라이언트에 반환 | `POST /api/v1/auth/kakao/callback` |
|
||||
| 12 | 자체 JWT 토큰 발급 후 클라이언트에 반환 | `POST /user/auth/kakao/callback` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ CastAD는 카카오 소셜 로그인만 지원하며, 인증 후 자체 JWT 토
|
|||
### 4.1 카카오 로그인 URL 요청
|
||||
|
||||
```
|
||||
GET /api/v1/auth/kakao/login
|
||||
GET /user/auth/kakao/login
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
|
@ -153,7 +153,7 @@ GET /api/v1/auth/kakao/login
|
|||
### 4.2 카카오 콜백 (로그인/가입 처리)
|
||||
|
||||
```
|
||||
POST /api/v1/auth/kakao/callback
|
||||
POST /user/auth/kakao/callback
|
||||
```
|
||||
|
||||
**Request:**
|
||||
|
|
@ -183,7 +183,7 @@ POST /api/v1/auth/kakao/callback
|
|||
### 4.3 토큰 갱신
|
||||
|
||||
```
|
||||
POST /api/v1/auth/refresh
|
||||
POST /user/auth/refresh
|
||||
```
|
||||
|
||||
**Request:**
|
||||
|
|
@ -205,14 +205,21 @@ POST /api/v1/auth/refresh
|
|||
### 4.4 로그아웃
|
||||
|
||||
```
|
||||
POST /api/v1/auth/logout
|
||||
POST /user/auth/logout
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
### 4.5 내 정보 조회
|
||||
### 4.5 모든 기기에서 로그아웃
|
||||
|
||||
```
|
||||
GET /api/v1/users/me
|
||||
POST /user/auth/logout/all
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
### 4.6 내 정보 조회
|
||||
|
||||
```
|
||||
GET /user/auth/me
|
||||
Authorization: Bearer {access_token}
|
||||
```
|
||||
|
||||
|
|
@ -312,20 +319,22 @@ class JWTSettings(BaseSettings):
|
|||
```
|
||||
app/user/
|
||||
├── __init__.py
|
||||
├── models.py # User 모델
|
||||
├── models.py # User, RefreshToken 모델
|
||||
├── exceptions.py # 사용자 정의 예외
|
||||
├── schemas/
|
||||
│ ├── __init__.py
|
||||
│ └── user_schema.py # Pydantic 스키마
|
||||
├── services/
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py # 인증 서비스
|
||||
│ ├── jwt.py # JWT 서비스
|
||||
│ └── kakao.py # 카카오 OAuth 서비스
|
||||
├── api/
|
||||
│ ├── jwt.py # JWT 유틸리티
|
||||
│ └── kakao.py # 카카오 OAuth 클라이언트
|
||||
├── dependencies/
|
||||
│ ├── __init__.py
|
||||
│ └── routers/
|
||||
│ └── v1/
|
||||
│ ├── __init__.py
|
||||
│ └── auth.py # 인증 API 라우터
|
||||
└── exceptions.py # 사용자 정의 예외
|
||||
│ └── auth.py # 인증 의존성 (get_current_user 등)
|
||||
└── api/
|
||||
└── routers/
|
||||
└── v1/
|
||||
├── __init__.py
|
||||
└── auth.py # 인증 API 라우터
|
||||
```
|
||||
|
|
|
|||
119
main.py
119
main.py
|
|
@ -1,5 +1,6 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
|
||||
|
|
@ -8,15 +9,82 @@ from app.core.common import lifespan
|
|||
from app.database.session import engine
|
||||
|
||||
# 주의: User 모델을 먼저 import해야 UserProject가 User를 참조할 수 있음
|
||||
from app.user.models import User # noqa: F401
|
||||
from app.user.models import User, RefreshToken # noqa: F401
|
||||
|
||||
from app.home.api.routers.v1.home import router as home_router
|
||||
from app.user.api.routers.v1.auth import router as auth_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.video.api.routers.v1.video import router as video_router
|
||||
from app.utils.cors import CustomCORSMiddleware
|
||||
from config import prj_settings
|
||||
|
||||
tags_metadata = [
|
||||
{
|
||||
"name": "Auth",
|
||||
"description": """카카오 소셜 로그인 및 JWT 토큰 관리 API
|
||||
|
||||
## 인증 흐름
|
||||
|
||||
1. `GET /api/v1/user/auth/kakao/login` - 카카오 로그인 URL 획득
|
||||
2. 사용자를 auth_url로 리다이렉트 → 카카오 로그인
|
||||
3. 카카오에서 인가 코드(code) 발급
|
||||
4. `POST /api/v1/user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
|
||||
5. 이후 API 호출 시 `Authorization: Bearer {access_token}` 헤더 사용
|
||||
|
||||
## 토큰 관리
|
||||
|
||||
- **Access Token**: 1시간 유효, API 호출 시 사용
|
||||
- **Refresh Token**: 7일 유효, Access Token 갱신 시 사용
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Home",
|
||||
"description": "홈 화면 및 프로젝트 관리 API",
|
||||
},
|
||||
{
|
||||
"name": "crawling",
|
||||
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"description": "이미지 업로드 API - 로컬 서버 또는 Azure Blob Storage",
|
||||
},
|
||||
{
|
||||
"name": "Lyric",
|
||||
"description": """가사 생성 및 관리 API
|
||||
|
||||
## 가사 생성 흐름
|
||||
|
||||
1. `POST /api/v1/lyric/generate` - 가사 생성 요청 (백그라운드 처리)
|
||||
2. `GET /api/v1/lyric/status/{task_id}` - 생성 상태 확인
|
||||
3. `GET /api/v1/lyric/{task_id}` - 생성된 가사 조회
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Song",
|
||||
"description": """노래 생성 및 관리 API (Suno AI)
|
||||
|
||||
## 노래 생성 흐름
|
||||
|
||||
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
|
||||
2. `GET /api/v1/song/status/{suno_task_id}` - Suno API 상태 확인
|
||||
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Video",
|
||||
"description": """영상 생성 및 관리 API (Creatomate)
|
||||
|
||||
## 영상 생성 흐름
|
||||
|
||||
1. `GET /api/v1/video/generate/{task_id}` - 영상 생성 요청
|
||||
2. `GET /api/v1/video/status/{creatomate_render_id}` - Creatomate 상태 확인
|
||||
3. `GET /api/v1/video/download/{task_id}` - 영상 다운로드 URL 조회
|
||||
""",
|
||||
},
|
||||
]
|
||||
|
||||
app = FastAPI(
|
||||
title=prj_settings.PROJECT_NAME,
|
||||
version=prj_settings.VERSION,
|
||||
|
|
@ -24,8 +92,50 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
docs_url=None, # 기본 Swagger UI 비활성화
|
||||
redoc_url=None, # 기본 ReDoc 비활성화
|
||||
openapi_tags=tags_metadata,
|
||||
)
|
||||
|
||||
|
||||
def custom_openapi():
|
||||
"""커스텀 OpenAPI 스키마 생성 (Bearer 인증 추가)"""
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
tags=tags_metadata,
|
||||
)
|
||||
|
||||
# Bearer 토큰 인증 스키마 추가
|
||||
openapi_schema["components"]["securitySchemes"] = {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "JWT 액세스 토큰을 입력하세요. 카카오 로그인 후 발급받은 access_token을 사용합니다.",
|
||||
}
|
||||
}
|
||||
|
||||
# 보안이 필요한 엔드포인트에 security 적용
|
||||
for path, path_item in openapi_schema["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method in ["get", "post", "put", "patch", "delete"]:
|
||||
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
|
||||
if any(
|
||||
auth_path in path
|
||||
for auth_path in ["/auth/me", "/auth/logout"]
|
||||
):
|
||||
operation["security"] = [{"BearerAuth": []}]
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
init_admin(app, engine)
|
||||
|
||||
custom_cors_middleware = CustomCORSMiddleware(app)
|
||||
|
|
@ -53,6 +163,7 @@ def get_scalar_docs():
|
|||
|
||||
|
||||
app.include_router(home_router)
|
||||
app.include_router(lyric_router) # Lyric API 라우터 추가
|
||||
app.include_router(song_router) # Song API 라우터 추가
|
||||
app.include_router(video_router) # Video API 라우터 추가
|
||||
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
|
||||
app.include_router(lyric_router)
|
||||
app.include_router(song_router)
|
||||
app.include_router(video_router)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ dependencies = [
|
|||
"fastapi[standard]>=0.125.0",
|
||||
"openai>=2.13.0",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"redis>=7.1.0",
|
||||
"ruff>=0.14.9",
|
||||
"scalar-fastapi>=1.5.0",
|
||||
|
|
|
|||
173
uv.lock
173
uv.lock
|
|
@ -179,6 +179,51 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
|
|
@ -200,6 +245,62 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
|
|
@ -218,6 +319,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
|
|
@ -769,6 +882,7 @@ dependencies = [
|
|||
{ name = "fastapi-cli" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "scalar-fastapi" },
|
||||
|
|
@ -794,6 +908,7 @@ requires-dist = [
|
|||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||
{ name = "openai", specifier = ">=2.13.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||
{ name = "redis", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.9" },
|
||||
{ name = "scalar-fastapi", specifier = ">=1.5.0" },
|
||||
|
|
@ -914,6 +1029,24 @@ wheels = [
|
|||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
|
|
@ -1056,6 +1189,25 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-jose"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ecdsa" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cryptography = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.21"
|
||||
|
|
@ -1190,6 +1342,18 @@ wheels = [
|
|||
{ 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]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.10"
|
||||
|
|
@ -1247,6 +1411,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
|
|
|
|||
Loading…
Reference in New Issue