사용자의 uuid -> ksuid로 변경, 테이블 구조 변경

insta
Dohyun Lim 2026-01-28 14:05:15 +09:00
parent dc7351d0f9
commit aa8d9d7c14
13 changed files with 170 additions and 75 deletions

View File

@ -149,7 +149,7 @@ class Project(Base):
id: 고유 식별자 (자동 증가)
store_name: 고객명 (필수)
region: 지역명 (필수, : 서울, 부산, 대구 )
task_id: 작업 고유 식별자 (UUID 형식, 36)
task_id: 작업 고유 식별자 (KSUID 형식, 27)
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
created_at: 생성 일시 (자동 설정)
@ -184,21 +184,20 @@ class Project(Base):
store_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
comment="가게명",
)
region: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
comment="지역명 (예: 군산)",
)
task_id: Mapped[str] = mapped_column(
String(36),
String(27),
nullable=False,
comment="프로젝트 작업 고유 식별자 (UUID)",
unique=True,
comment="프로젝트 작업 고유 식별자 (KSUID)",
)
detail_region_info: Mapped[Optional[str]] = mapped_column(
@ -281,7 +280,7 @@ class Image(Base):
Attributes:
id: 고유 식별자 (자동 증가)
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
task_id: 이미지 업로드 작업 고유 식별자 (KSUID)
img_name: 이미지명
img_url: 이미지 URL (S3, CDN 등의 경로)
created_at: 생성 일시 (자동 설정)
@ -289,6 +288,7 @@ class Image(Base):
__tablename__ = "image"
__table_args__ = (
Index("idx_image_task_id", "task_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -305,9 +305,10 @@ class Image(Base):
)
task_id: Mapped[str] = mapped_column(
String(36),
String(27),
nullable=False,
comment="이미지 업로드 작업 고유 식별자 (UUID)",
unique=True,
comment="이미지 업로드 작업 고유 식별자 (KSUID)",
)
img_name: Mapped[str] = mapped_column(

View File

@ -91,7 +91,7 @@ class GenerateUrlsRequest(GenerateRequestInfo):
class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
task_id: str = Field(..., description="작업 고유 식별자 (KSUID)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
@ -102,7 +102,7 @@ class GenerateUploadResponse(BaseModel):
class GenerateResponse(BaseModel):
"""생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
task_id: str = Field(..., description="작업 고유 식별자 (KSUID)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
@ -291,7 +291,7 @@ class ImageUploadResponse(BaseModel):
}
)
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 KSUID)")
total_count: int = Field(..., description="총 업로드된 이미지 개수")
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -23,7 +23,7 @@ class Lyric(Base):
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
task_id: 가사 생성 작업의 고유 식별자 (KSUID 형식)
status: 처리 상태 (pending, processing, completed, failed )
lyric_prompt: 가사 생성에 사용된 프롬프트
lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원)
@ -37,6 +37,8 @@ class Lyric(Base):
__tablename__ = "lyric"
__table_args__ = (
Index("idx_lyric_task_id", "task_id"),
Index("idx_lyric_project_id", "project_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -56,14 +58,14 @@ class Lyric(Base):
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
String(27),
nullable=False,
comment="가사 생성 작업 고유 식별자 (UUID)",
unique=True,
comment="가사 생성 작업 고유 식별자 (KSUID)",
)
status: Mapped[str] = mapped_column(

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -23,7 +23,7 @@ class Song(Base):
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
task_id: 노래 생성 작업의 고유 식별자 (KSUID 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (processing, uploading, completed, failed)
song_prompt: 노래 생성에 사용된 프롬프트
@ -39,6 +39,9 @@ class Song(Base):
__tablename__ = "song"
__table_args__ = (
Index("idx_song_task_id", "task_id"),
Index("idx_song_project_id", "project_id"),
Index("idx_song_lyric_id", "lyric_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -58,7 +61,6 @@ class Song(Base):
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
@ -66,14 +68,14 @@ class Song(Base):
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
String(27),
nullable=False,
comment="노래 생성 작업 고유 식별자 (UUID)",
unique=True,
comment="노래 생성 작업 고유 식별자 (KSUID)",
)
suno_task_id: Mapped[Optional[str]] = mapped_column(
@ -177,6 +179,7 @@ class SongTimestamp(Base):
__tablename__ = "song_timestamp"
__table_args__ = (
Index("idx_song_timestamp_suno_audio_id", "suno_audio_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -195,7 +198,6 @@ class SongTimestamp(Base):
suno_audio_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
comment="가사의 원본 오디오 ID",
)

View File

@ -58,14 +58,14 @@ async def get_current_user(
if payload.get("type") != "access":
raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_id = payload.get("sub")
if user_id is None:
user_ksuid = payload.get("sub")
if user_ksuid is None:
raise InvalidTokenError()
# 사용자 조회
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.user_ksuid == user_ksuid,
User.is_deleted == False, # noqa: E712
)
)
@ -106,13 +106,13 @@ async def get_current_user_optional(
if payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
user_ksuid = payload.get("sub")
if user_ksuid is None:
return None
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.user_ksuid == user_ksuid,
User.is_deleted == False, # noqa: E712
)
)

View File

@ -26,6 +26,7 @@ class User(Base):
Attributes:
id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID (필수, 유니크)
user_ksuid: 사용자 식별을 위한 KSUID (필수, 유니크)
email: 이메일 주소 (선택, 카카오에서 제공 )
nickname: 카카오 닉네임 (선택)
profile_image_url: 카카오 프로필 이미지 URL (선택)
@ -68,7 +69,8 @@ class User(Base):
__tablename__ = "user"
__table_args__ = (
Index("idx_user_kakao_id", "kakao_id", unique=True),
Index("idx_user_kakao_id", "kakao_id"),
Index("idx_user_ksuid", "user_ksuid"),
Index("idx_user_email", "email"),
Index("idx_user_phone", "phone"),
Index("idx_user_is_active", "is_active"),
@ -103,6 +105,13 @@ class User(Base):
comment="카카오 고유 ID (회원번호)",
)
user_ksuid: Mapped[str] = mapped_column(
String(27),
nullable=False,
unique=True,
comment="사용자 식별을 위한 KSUID (K-Sortable Unique Identifier)",
)
# ==========================================================================
# 카카오에서 제공하는 사용자 정보 (선택적)
# ==========================================================================
@ -282,6 +291,7 @@ class RefreshToken(Base):
Attributes:
id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조)
user_ksuid: 사용자 KSUID (User.user_ksuid 참조)
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
expires_at: 토큰 만료 일시
is_revoked: 토큰 폐기 여부 (로그아웃 True)
@ -299,6 +309,7 @@ class RefreshToken(Base):
__tablename__ = "refresh_token"
__table_args__ = (
Index("idx_refresh_token_user_id", "user_id"),
Index("idx_refresh_token_user_ksuid", "user_ksuid"),
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"),
@ -324,6 +335,12 @@ class RefreshToken(Base):
comment="사용자 외래키 (User.id 참조)",
)
user_ksuid: Mapped[str] = mapped_column(
String(27),
nullable=False,
comment="사용자 KSUID (User.user_ksuid 참조)",
)
token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,

View File

@ -156,13 +156,13 @@ class UserBriefResponse(BaseModel):
class LoginResponse(BaseModel):
"""로그인 응답 (토큰 + 사용자 정보)"""
"""로그인 응답 (토큰 정보)"""
access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
user: UserBriefResponse = Field(..., description="사용자 정보")
is_new_user: bool = Field(..., description="신규 가입 여부")
redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL")
model_config = {
@ -172,13 +172,7 @@ class LoginResponse(BaseModel):
"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
},
"is_new_user": False,
"redirect_url": "http://localhost:3000"
}
}

View File

@ -24,11 +24,11 @@ from app.user.exceptions import (
UserNotFoundError,
)
from app.user.models import RefreshToken, User
from app.utils.common import generate_ksuid
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo,
LoginResponse,
UserBriefResponse,
)
from app.user.services.jwt import (
create_access_token,
@ -96,27 +96,28 @@ class AuthService:
# 5. JWT 토큰 생성
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_id: {user.id}")
access_token = create_access_token(user.user_ksuid)
refresh_token = create_refresh_token(user.user_ksuid)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_ksuid: {user.user_ksuid}")
# 6. 리프레시 토큰 DB 저장
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
await self._save_refresh_token(
user_id=user.id,
user_ksuid=user.user_ksuid,
token=refresh_token,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}")
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_ksuid: {user.user_ksuid}")
# 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}")
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, user_ksuid: {user.user_ksuid}, redirect_url: {redirect_url}")
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
return LoginResponse(
@ -124,13 +125,7 @@ class AuthService:
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,
),
is_new_user=is_new_user,
redirect_url=redirect_url,
)
@ -177,8 +172,8 @@ class AuthService:
raise TokenExpiredError()
# 4. 사용자 확인
user_id = int(payload.get("sub"))
user = await self._get_user_by_id(user_id, session)
user_ksuid = payload.get("sub")
user = await self._get_user_by_ksuid(user_ksuid, session)
if user is None:
raise UserNotFoundError()
@ -187,7 +182,7 @@ class AuthService:
raise UserInactiveError()
# 5. 새 액세스 토큰 발급
new_access_token = create_access_token(user.id)
new_access_token = create_access_token(user.user_ksuid)
return AccessTokenResponse(
access_token=new_access_token,
@ -269,8 +264,10 @@ class AuthService:
# 신규 사용자 생성
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
ksuid = await generate_ksuid(session=session, table_name=User)
new_user = User(
kakao_id=kakao_id,
user_ksuid=ksuid,
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,
@ -319,6 +316,7 @@ class AuthService:
async def _save_refresh_token(
self,
user_id: int,
user_ksuid: str,
token: str,
session: AsyncSession,
user_agent: Optional[str] = None,
@ -329,6 +327,7 @@ class AuthService:
Args:
user_id: 사용자 ID
user_ksuid: 사용자 KSUID
token: 리프레시 토큰
session: DB 세션
user_agent: User-Agent
@ -342,6 +341,7 @@ class AuthService:
refresh_token = RefreshToken(
user_id=user_id,
user_ksuid=user_ksuid,
token_hash=token_hash,
expires_at=expires_at,
user_agent=user_agent,
@ -391,6 +391,26 @@ class AuthService:
)
return result.scalar_one_or_none()
async def _get_user_by_ksuid(
self,
user_ksuid: str,
session: AsyncSession,
) -> Optional[User]:
"""
KSUID로 사용자 조회
Args:
user_ksuid: 사용자 KSUID
session: DB 세션
Returns:
User 객체 또는 None
"""
result = await session.execute(
select(User).where(User.user_ksuid == user_ksuid, User.is_deleted == False) # noqa: E712
)
return result.scalar_one_or_none()
async def _revoke_refresh_token_by_hash(
self,
token_hash: str,

View File

@ -13,12 +13,12 @@ from jose import JWTError, jwt
from config import jwt_settings
def create_access_token(user_id: int) -> str:
def create_access_token(user_ksuid: str) -> str:
"""
JWT 액세스 토큰 생성
Args:
user_id: 사용자 ID
user_ksuid: 사용자 KSUID
Returns:
JWT 액세스 토큰 문자열
@ -27,7 +27,7 @@ def create_access_token(user_id: int) -> str:
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
"sub": str(user_id),
"sub": user_ksuid,
"exp": expire,
"type": "access",
}
@ -38,12 +38,12 @@ def create_access_token(user_id: int) -> str:
)
def create_refresh_token(user_id: int) -> str:
def create_refresh_token(user_ksuid: str) -> str:
"""
JWT 리프레시 토큰 생성
Args:
user_id: 사용자 ID
user_ksuid: 사용자 KSUID
Returns:
JWT 리프레시 토큰 문자열
@ -52,7 +52,7 @@ def create_refresh_token(user_id: int) -> str:
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode = {
"sub": str(user_id),
"sub": user_ksuid,
"exp": expire,
"type": "refresh",
}

View File

@ -4,11 +4,14 @@ Common Utility Functions
공통으로 사용되는 유틸리티 함수들을 정의합니다.
사용 예시:
from app.utils.common import generate_task_id
from app.utils.common import generate_task_id, generate_ksuid
# task_id 생성
task_id = await generate_task_id(session=session, table_name=Project)
# ksuid 생성
ksuid = await generate_ksuid(session=session, table_name=User)
Note:
페이지네이션 기능은 app.utils.pagination 모듈을 사용하세요:
from app.utils.pagination import PaginatedResponse, get_paginated
@ -18,7 +21,7 @@ from typing import Any, Optional, Type
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from uuid_extensions import uuid7
from svix_ksuid import Ksuid
async def generate_task_id(
@ -32,16 +35,16 @@ async def generate_task_id(
table_name: task_id 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
Returns:
str: 생성된 uuid7 문자열
str: 생성된 ksuid 문자열
Usage:
# 단순 uuid7 생성
# 단순 ksuid 생성
task_id = await generate_task_id()
# 테이블에서 중복 검사 후 생성
task_id = await generate_task_id(session=session, table_name=Project)
"""
task_id = str(uuid7())
task_id = str(Ksuid())
if session is None or table_name is None:
return task_id
@ -55,4 +58,41 @@ async def generate_task_id(
if existing is None:
return task_id
task_id = str(uuid7())
task_id = str(Ksuid())
async def generate_ksuid(
session: Optional[AsyncSession] = None,
table_name: Optional[Type[Any]] = None,
) -> str:
"""고유한 ksuid를 생성합니다.
Args:
session: SQLAlchemy AsyncSession (optional)
table_name: user_ksuid 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
Returns:
str: 생성된 ksuid 문자열
Usage:
# 단순 ksuid 생성
ksuid = await generate_ksuid()
# 테이블에서 중복 검사 후 생성
ksuid = await generate_ksuid(session=session, table_name=User)
"""
ksuid = str(Ksuid())
if session is None or table_name is None:
return ksuid
while True:
result = await session.execute(
select(table_name).where(table_name.user_ksuid == ksuid)
)
existing = result.scalar_one_or_none()
if existing is None:
return ksuid
ksuid = str(Ksuid())

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -24,7 +24,7 @@ class Video(Base):
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
song_id: 연결된 Song의 id (외래키)
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
task_id: 영상 생성 작업의 고유 식별자 (KSUID 형식)
status: 처리 상태 (pending, processing, completed, failed )
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
created_at: 생성 일시 (자동 설정)
@ -37,6 +37,10 @@ class Video(Base):
__tablename__ = "video"
__table_args__ = (
Index("idx_video_task_id", "task_id"),
Index("idx_video_project_id", "project_id"),
Index("idx_video_lyric_id", "lyric_id"),
Index("idx_video_song_id", "song_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -56,7 +60,6 @@ class Video(Base):
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
@ -64,7 +67,6 @@ class Video(Base):
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
@ -72,15 +74,14 @@ class Video(Base):
Integer,
ForeignKey("song.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Song의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
String(27),
nullable=False,
index=True,
comment="영상 생성 작업 고유 식별자 (UUID)",
unique=True,
comment="영상 생성 작업 고유 식별자 (KSUID)",
)
creatomate_render_id: Mapped[Optional[str]] = mapped_column(

View File

@ -22,6 +22,7 @@ dependencies = [
"scalar-fastapi>=1.6.1",
"sqladmin[full]>=0.22.0",
"sqlalchemy[asyncio]>=2.0.45",
"svix-ksuid>=0.6.2",
"uuid7>=0.1.0",
]

17
uv.lock
View File

@ -725,6 +725,7 @@ dependencies = [
{ name = "scalar-fastapi" },
{ name = "sqladmin", extra = ["full"] },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "svix-ksuid" },
{ name = "uuid7" },
]
@ -753,6 +754,7 @@ requires-dist = [
{ name = "scalar-fastapi", specifier = ">=1.6.1" },
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
{ name = "svix-ksuid", specifier = ">=0.6.2" },
{ name = "uuid7", specifier = ">=0.1.0" },
]
@ -1019,6 +1021,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-baseconv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/d0/9297d7d8dd74767b4d5560d834b30b2fff17d39987c23ed8656f476e0d9b/python-baseconv-1.2.2.tar.gz", hash = "sha256:0539f8bd0464013b05ad62e0a1673f0ac9086c76b43ebf9f833053527cd9931b", size = 4929, upload-time = "2019-04-04T19:28:57.17Z" }
[[package]]
name = "python-dotenv"
version = "1.2.1"
@ -1314,6 +1322,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
name = "svix-ksuid"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-baseconv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/7a/0c98b77ca01d64f13143607b88273a13110d659780b93cb1333abebf8039/svix-ksuid-0.6.2.tar.gz", hash = "sha256:beb95bd6284bdbd526834e233846653d2bd26eb162b3233513d8f2c853c78964", size = 6957, upload-time = "2023-07-07T09:18:24.717Z" }
[[package]]
name = "tqdm"
version = "4.67.1"