From 369f530ec521da0fdee9f17ebfcf8f5cff829352 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Sun, 21 Dec 2025 22:49:32 +0900 Subject: [PATCH] add crawler and generate --- .gitignore | 3 + app/home/api/routers/v1/home.py | 869 +++-- app/home/api/routers/v1/sample.py | 463 +++ app/home/models.py | 43 +- app/home/schemas/.gitkeep | 0 app/home/schemas/home.py | 144 + app/lyric/models.py | 17 +- app/song/models.py | 17 +- app/utils/nvMapScraper.py | 112 + app/video/models.py | 15 +- config.py | 7 + docs/reference/ConfigDict.md | 382 +++ docs/reference/fastapi_sqlalchemy_guide.md | 2965 +++++++++++++++++ .../sqlalchemy_relationship_guide.md | 1440 ++++++++ o2o_castad_backend.egg-info/PKG-INFO | 3 + o2o_castad_backend.egg-info/SOURCES.txt | 3 + o2o_castad_backend.egg-info/requires.txt | 3 + pyproject.toml | 5 +- uv.lock | 423 +++ 19 files changed, 6426 insertions(+), 488 deletions(-) create mode 100644 app/home/api/routers/v1/sample.py delete mode 100644 app/home/schemas/.gitkeep create mode 100644 app/home/schemas/home.py create mode 100644 app/utils/nvMapScraper.py create mode 100644 docs/reference/ConfigDict.md create mode 100644 docs/reference/fastapi_sqlalchemy_guide.md create mode 100644 docs/reference/sqlalchemy_relationship_guide.md diff --git a/.gitignore b/.gitignore index cf0123a..7b72bcf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ __pycache__/ # VSCode settings .vscode/ + +# Python package metadata +*.egg-info/ diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 90ce39f..736c527 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -1,463 +1,452 @@ -from datetime import datetime -from typing import Optional +import json +from datetime import date +from pathlib import Path -from fastapi import APIRouter, Body, Depends, status -from pydantic import BaseModel, EmailStr, Field -from sqlalchemy import text +import aiofiles +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from uuid_extensions import uuid7 from app.database.session import get_session +from app.home.models import Image, Project +from app.home.schemas.home import ( + AttributeInfo, + CrawlingRequest, + CrawlingResponse, + ErrorResponse, + GenerateRequest, + GenerateResponse, + GenerateUploadResponse, + GenerateUrlsRequest, + ProcessedInfo, +) +from app.utils.nvMapScraper import NvMapScraper + +MEDIA_ROOT = Path("media") + +# 전국 시/군/구 이름 목록 (roadAddress에서 region 추출용) +KOREAN_CITIES = [ + # 특별시/광역시 + "서울시", + "부산시", + "대구시", + "인천시", + "광주시", + "대전시", + "울산시", + "세종시", + # 경기도 + "수원시", + "성남시", + "고양시", + "용인시", + "부천시", + "안산시", + "안양시", + "남양주시", + "화성시", + "평택시", + "의정부시", + "시흥시", + "파주시", + "광명시", + "김포시", + "군포시", + "광주시", + "이천시", + "양주시", + "오산시", + "구리시", + "안성시", + "포천시", + "의왕시", + "하남시", + "여주시", + "동두천시", + "과천시", + # 강원도 + "춘천시", + "원주시", + "강릉시", + "동해시", + "태백시", + "속초시", + "삼척시", + # 충청북도 + "청주시", + "충주시", + "제천시", + # 충청남도 + "천안시", + "공주시", + "보령시", + "아산시", + "서산시", + "논산시", + "계룡시", + "당진시", + # 전라북도 + "전주시", + "군산시", + "익산시", + "정읍시", + "남원시", + "김제시", + # 전라남도 + "목포시", + "여수시", + "순천시", + "나주시", + "광양시", + # 경상북도 + "포항시", + "경주시", + "김천시", + "안동시", + "구미시", + "영주시", + "영천시", + "상주시", + "문경시", + "경산시", + # 경상남도 + "창원시", + "진주시", + "통영시", + "사천시", + "김해시", + "밀양시", + "거제시", + "양산시", + # 제주도 + "제주시", + "서귀포시", +] router = APIRouter() -# ============================================================ -# Pydantic Models for Request/Response -# ============================================================ - - -class SigninRequest(BaseModel): - """회원가입 요청 스키마""" - - email: EmailStr = Field( - ..., description="사용자 이메일 주소", example="user@example.com" - ) - password: str = Field( - ..., min_length=8, description="비밀번호 (최소 8자)", example="password123" - ) - name: str = Field( - ..., min_length=2, max_length=50, description="사용자 이름", example="홍길동" - ) - phone: Optional[str] = Field( - None, - pattern=r"^\d{3}-\d{4}-\d{4}$", - description="전화번호 (형식: 010-1234-5678)", - example="010-1234-5678", - ) - - -class SigninResponse(BaseModel): - """회원가입 응답 스키마""" - - success: bool = Field(..., description="요청 성공 여부") - message: str = Field(..., description="응답 메시지") - user_id: int = Field(..., description="생성된 사용자 ID") - email: EmailStr = Field(..., description="등록된 이메일") - created_at: datetime = Field(..., description="계정 생성 시간") - - -class LoginRequest(BaseModel): - """로그인 요청 스키마""" - - email: EmailStr = Field( - ..., description="사용자 이메일 주소", example="user@example.com" - ) - password: str = Field(..., description="비밀번호", example="password123") - - -class LoginResponse(BaseModel): - """로그인 응답 스키마""" - - success: bool = Field(..., description="로그인 성공 여부") - message: str = Field(..., description="응답 메시지") - access_token: str = Field(..., description="JWT 액세스 토큰") - refresh_token: str = Field(..., description="JWT 리프레시 토큰") - token_type: str = Field(default="bearer", description="토큰 타입") - expires_in: int = Field(..., description="토큰 만료 시간 (초)") - - -class LogoutResponse(BaseModel): - """로그아웃 응답 스키마""" - - success: bool = Field(..., description="로그아웃 성공 여부") - message: str = Field(..., description="응답 메시지") - - -class ProfileResponse(BaseModel): - """프로필 조회 응답 스키마""" - - user_id: int = Field(..., description="사용자 ID") - email: EmailStr = Field(..., description="이메일 주소") - name: str = Field(..., description="사용자 이름") - phone: Optional[str] = Field(None, description="전화번호") - profile_image: Optional[str] = Field(None, description="프로필 이미지 URL") - created_at: datetime = Field(..., description="계정 생성 시간") - last_login: Optional[datetime] = Field(None, description="마지막 로그인 시간") - - -class HomeResponse(BaseModel): - """홈 응답 스키마""" - - message: str = Field(..., description="환영 메시지") - version: str = Field(..., description="API 버전") - status: str = Field(..., description="서비스 상태") - timestamp: datetime = Field(..., description="응답 시간") - - -class ErrorResponse(BaseModel): - """에러 응답 스키마""" - - success: bool = Field(default=False, description="요청 성공 여부") - error_code: str = Field(..., description="에러 코드") - message: str = Field(..., description="에러 메시지") - detail: Optional[str] = Field(None, description="상세 에러 정보") - - -# ============================================================ -# Dummy Data -# ============================================================ - -DUMMY_USER = { - "user_id": 1, - "email": "user@example.com", - "name": "홍길동", - "phone": "010-1234-5678", - "profile_image": "https://example.com/images/profile/default.png", - "created_at": datetime(2024, 1, 15, 10, 30, 0), - "last_login": datetime(2024, 12, 18, 9, 0, 0), -} - -DUMMY_TOKENS = { - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzM0NTAwMDAwfQ.dummy_signature", - "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzM1MDAwMDAwfQ.dummy_refresh", - "token_type": "bearer", - "expires_in": 3600, -} - - -# ============================================================ -# Endpoints -# ============================================================ - - -@router.get( - "/db", - summary="데이터베이스 상태 확인", - description="데이터베이스 연결 상태를 확인합니다. 간단한 쿼리를 실행하여 DB 연결이 정상인지 테스트합니다.", - response_description="데이터베이스 연결 상태 정보", - responses={ - 200: { - "description": "데이터베이스 연결 정상", - "content": { - "application/json": { - "example": { - "status": "healthy", - "database": "connected", - "test_query": 1, - } - } - }, - }, - 500: {"description": "데이터베이스 연결 실패", "model": ErrorResponse}, - }, - tags=["health"], -) -async def db_health_check(session: AsyncSession = Depends(get_session)): - """DB 연결 상태 확인""" - try: - result = await session.execute(text("SELECT 1")) - return { - "status": "healthy", - "database": "connected", - "test_query": result.scalar(), - } - except Exception as e: - return {"status": "unhealthy", "database": "disconnected", "error": str(e)} - - -@router.get( - "/", - summary="홈 엔드포인트", - description="API 서비스의 기본 정보를 반환합니다. 서비스 상태, 버전, 현재 시간 등의 정보를 확인할 수 있습니다.", - response_model=HomeResponse, - response_description="서비스 기본 정보", - responses={ - 200: { - "description": "성공적으로 홈 정보 반환", - "content": { - "application/json": { - "example": { - "message": "CASTAD API 서비스에 오신 것을 환영합니다.", - "version": "0.1.0", - "status": "running", - "timestamp": "2024-12-18T10:00:00", - } - } - }, - } - }, - tags=["home"], -) -async def home() -> HomeResponse: - """홈 페이지 - API 기본 정보 반환""" - return HomeResponse( - message="CASTAD API 서비스에 오신 것을 환영합니다.", - version="0.1.0", - status="running", - timestamp=datetime.now(), - ) +def _extract_region_from_address(road_address: str | None) -> str: + """roadAddress에서 시 이름 추출""" + if not road_address: + return "" + for city in KOREAN_CITIES: + if city in road_address: + return city + return "" @router.post( - "/signin", - summary="회원가입", + "/crawling", + summary="네이버 지도 크롤링", description=""" -새로운 사용자 계정을 생성합니다. +네이버 지도 장소 URL을 입력받아 이미지 목록과 기본 정보를 크롤링합니다. ## 요청 필드 -- **email**: 유효한 이메일 주소 (필수) -- **password**: 최소 8자 이상의 비밀번호 (필수) -- **name**: 2~50자 사이의 사용자 이름 (필수) -- **phone**: 전화번호 (선택, 형식: 010-1234-5678) - -## 비밀번호 정책 -- 최소 8자 이상 -- 영문, 숫자 조합 권장 - """, - response_model=SigninResponse, - response_description="회원가입 결과", - status_code=status.HTTP_201_CREATED, - responses={ - 201: {"description": "회원가입 성공", "model": SigninResponse}, - 400: { - "description": "잘못된 요청 (유효성 검사 실패)", - "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "error_code": "VALIDATION_ERROR", - "message": "입력값이 유효하지 않습니다.", - "detail": "이메일 형식이 올바르지 않습니다.", - } - } - }, - }, - 409: { - "description": "이미 존재하는 이메일", - "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "error_code": "EMAIL_EXISTS", - "message": "이미 등록된 이메일입니다.", - "detail": None, - } - } - }, - }, - }, - tags=["auth"], -) -async def signin( - request_body: SigninRequest = Body( - ..., - description="회원가입에 필요한 사용자 정보", - openapi_examples={ - "기본 예시": { - "summary": "필수 필드만 입력", - "description": "이메일, 비밀번호, 이름만 입력하는 경우", - "value": { - "email": "newuser@example.com", - "password": "securepass123", - "name": "김철수", - }, - }, - "전체 필드 예시": { - "summary": "모든 필드 입력", - "description": "선택 필드를 포함한 전체 입력", - "value": { - "email": "newuser@example.com", - "password": "securepass123", - "name": "김철수", - "phone": "010-9876-5432", - }, - }, - }, - ), -) -> SigninResponse: - """새로운 사용자 회원가입 처리""" - return SigninResponse( - success=True, - message="회원가입이 완료되었습니다.", - user_id=2, - email=request_body.email, - created_at=datetime.now(), - ) - - -@router.post( - "/login", - summary="로그인", - description=""" -사용자 인증을 수행하고 JWT 토큰을 발급합니다. - -## 인증 방식 -이메일과 비밀번호를 사용한 기본 인증을 수행합니다. -인증 성공 시 액세스 토큰과 리프레시 토큰이 발급됩니다. - -## 토큰 정보 -- **access_token**: API 요청 시 사용 (유효기간: 1시간) -- **refresh_token**: 액세스 토큰 갱신 시 사용 (유효기간: 7일) - """, - response_model=LoginResponse, - response_description="로그인 결과 및 토큰 정보", - responses={ - 200: {"description": "로그인 성공", "model": LoginResponse}, - 401: { - "description": "인증 실패", - "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "error_code": "INVALID_CREDENTIALS", - "message": "이메일 또는 비밀번호가 올바르지 않습니다.", - "detail": None, - } - } - }, - }, - 403: { - "description": "계정 비활성화", - "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "error_code": "ACCOUNT_DISABLED", - "message": "비활성화된 계정입니다.", - "detail": "관리자에게 문의하세요.", - } - } - }, - }, - }, - tags=["auth"], -) -async def login( - request_body: LoginRequest = Body( - ..., - description="로그인 인증 정보", - openapi_examples={ - "로그인 예시": { - "summary": "일반 로그인", - "description": "이메일과 비밀번호로 로그인", - "value": {"email": "user@example.com", "password": "password123"}, - } - }, - ), -) -> LoginResponse: - """사용자 로그인 및 토큰 발급""" - return LoginResponse( - success=True, - message="로그인에 성공했습니다.", - access_token=DUMMY_TOKENS["access_token"], - refresh_token=DUMMY_TOKENS["refresh_token"], - token_type=DUMMY_TOKENS["token_type"], - expires_in=DUMMY_TOKENS["expires_in"], - ) - - -@router.post( - "/logout", - summary="로그아웃", - description=""" -현재 세션을 종료하고 토큰을 무효화합니다. - -## 동작 방식 -- 서버 측 토큰 블랙리스트에 현재 토큰 등록 -- 클라이언트 측 토큰 삭제 권장 - -## 주의사항 -로그아웃 후에는 동일한 토큰으로 API 요청이 불가능합니다. - """, - response_model=LogoutResponse, - response_description="로그아웃 결과", - responses={ - 200: {"description": "로그아웃 성공", "model": LogoutResponse}, - 401: { - "description": "인증되지 않은 요청", - "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "error_code": "UNAUTHORIZED", - "message": "인증이 필요합니다.", - "detail": "유효한 토큰을 제공해주세요.", - } - } - }, - }, - }, - tags=["auth"], -) -async def logout() -> LogoutResponse: - """사용자 로그아웃 처리""" - return LogoutResponse(success=True, message="로그아웃되었습니다.") - - -@router.get( - "/profile", - summary="프로필 조회", - description=""" -현재 로그인한 사용자의 프로필 정보를 조회합니다. +- **url**: 네이버 지도 장소 URL (필수) ## 반환 정보 -- 기본 정보: 사용자 ID, 이메일, 이름 -- 연락처 정보: 전화번호 -- 프로필 이미지 URL -- 계정 정보: 생성일, 마지막 로그인 시간 - -## 인증 필요 -이 엔드포인트는 유효한 액세스 토큰이 필요합니다. -Authorization 헤더에 Bearer 토큰을 포함해주세요. +- **image_list**: 장소 이미지 URL 목록 +- **image_count**: 이미지 개수 +- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info) """, - response_model=ProfileResponse, - response_description="사용자 프로필 정보", + response_model=CrawlingResponse, + response_description="크롤링 결과", responses={ - 200: {"description": "프로필 조회 성공", "model": ProfileResponse}, - 401: { - "description": "인증되지 않은 요청", + 200: {"description": "크롤링 성공", "model": CrawlingResponse}, + 400: { + "description": "잘못된 URL", "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "error_code": "UNAUTHORIZED", - "message": "인증이 필요합니다.", - "detail": "유효한 토큰을 제공해주세요.", - } - } - }, - }, - 404: { - "description": "사용자를 찾을 수 없음", - "model": ErrorResponse, - "content": { - "application/json": { - "example": { - "success": False, - "error_code": "USER_NOT_FOUND", - "message": "사용자를 찾을 수 없습니다.", - "detail": None, - } - } - }, }, }, - tags=["user"], + tags=["crawling"], ) -async def profile() -> ProfileResponse: - """현재 사용자 프로필 조회""" - return ProfileResponse( - user_id=DUMMY_USER["user_id"], - email=DUMMY_USER["email"], - name=DUMMY_USER["name"], - phone=DUMMY_USER["phone"], - profile_image=DUMMY_USER["profile_image"], - created_at=DUMMY_USER["created_at"], - last_login=DUMMY_USER["last_login"], +async def crawling(request_body: CrawlingRequest): + """네이버 지도 장소 크롤링""" + scraper = NvMapScraper(request_body.url) + await scraper.scrap() + + # 가공된 정보 생성 + processed_info = None + if scraper.base_info: + road_address = scraper.base_info.get("roadAddress", "") + processed_info = ProcessedInfo( + customer_name=scraper.base_info.get("name", ""), + region=_extract_region_from_address(road_address), + detail_region_info=road_address or "", + ) + + return { + "image_list": scraper.image_link_list, + "image_count": len(scraper.image_link_list) if scraper.image_link_list else 0, + "processed_info": processed_info, + } + + +def _extract_image_name(url: str, index: int) -> str: + """URL에서 이미지 이름 추출 또는 기본 이름 생성""" + try: + from urllib.parse import unquote, urlparse + + path = urlparse(url).path + filename = path.split("/")[-1] if path else "" + if filename: + return unquote(filename) + except Exception: + pass + return f"image_{index + 1:03d}" + + +@router.post( + "/generate", + summary="기본 영상 생성 요청", + description=""" +고객 정보만 받아 영상 생성 작업을 시작합니다. (이미지 없음) + +## 요청 필드 +- **customer_name**: 고객명/가게명 (필수) +- **region**: 지역명 (필수) +- **detail_region_info**: 상세 지역 정보 (선택) +- **attribute**: 음악 속성 정보 (genre, vocal, tempo, mood) + +## 반환 정보 +- **task_id**: 작업 고유 식별자 (UUID7) +- **status**: 작업 상태 +- **message**: 응답 메시지 + """, + response_model=GenerateResponse, + response_description="생성 작업 시작 결과", + tags=["generate"], +) +async def generate( + request_body: GenerateRequest, + session: AsyncSession = Depends(get_session), +): + """기본 영상 생성 요청 처리 (이미지 없음)""" + # UUID7 생성 및 중복 검사 + while True: + task_id = str(uuid7()) + existing = await session.execute( + select(Project).where(Project.task_id == task_id) + ) + if existing.scalar_one_or_none() is None: + break + + # Project 생성 (이미지 없음) + project = Project( + store_name=request_body.customer_name, + region=request_body.region, + task_id=task_id, + detail_region_info=json.dumps( + { + "detail": request_body.detail_region_info, + "attribute": request_body.attribute.model_dump(), + }, + ensure_ascii=False, + ), ) + session.add(project) + await session.commit() + + return { + "task_id": task_id, + "status": "processing", + "message": "생성 작업이 시작되었습니다.", + } + + +@router.post( + "/generate/urls", + summary="URL 기반 영상 생성 요청", + description=""" +고객 정보와 이미지 URL을 받아 영상 생성 작업을 시작합니다. + +## 요청 필드 +- **customer_name**: 고객명/가게명 (필수) +- **region**: 지역명 (필수) +- **detail_region_info**: 상세 지역 정보 (선택) +- **attribute**: 음악 속성 정보 (genre, vocal, tempo, mood) +- **images**: 이미지 URL 목록 (필수) + +## 반환 정보 +- **task_id**: 작업 고유 식별자 (UUID7) +- **status**: 작업 상태 +- **message**: 응답 메시지 + """, + response_model=GenerateResponse, + response_description="생성 작업 시작 결과", + tags=["generate"], +) +async def generate_urls( + request_body: GenerateUrlsRequest, + session: AsyncSession = Depends(get_session), +): + """URL 기반 영상 생성 요청 처리""" + # UUID7 생성 및 중복 검사 + while True: + task_id = str(uuid7()) + existing = await session.execute( + select(Project).where(Project.task_id == task_id) + ) + if existing.scalar_one_or_none() is None: + break + + # Project 생성 (이미지 정보 제외) + project = Project( + store_name=request_body.customer_name, + region=request_body.region, + task_id=task_id, + detail_region_info=json.dumps( + { + "detail": request_body.detail_region_info, + "attribute": request_body.attribute.model_dump(), + }, + ensure_ascii=False, + ), + ) + session.add(project) + + # Image 레코드 생성 (독립 테이블, task_id로 연결) + for idx, img_item in enumerate(request_body.images): + # name이 있으면 사용, 없으면 URL에서 추출 + img_name = img_item.name or _extract_image_name(img_item.url, idx) + image = Image( + task_id=task_id, + img_name=img_name, + img_url=img_item.url, + img_order=idx, + ) + session.add(image) + + await session.commit() + + return { + "task_id": task_id, + "status": "processing", + "message": "생성 작업이 시작되었습니다.", + } + + +async def _save_upload_file(file: UploadFile, save_path: Path) -> None: + """업로드 파일을 지정된 경로에 저장""" + save_path.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(save_path, "wb") as f: + content = await file.read() + await f.write(content) + + +def _get_file_extension(filename: str | None) -> str: + """파일명에서 확장자 추출""" + if not filename: + return ".jpg" + ext = Path(filename).suffix.lower() + return ext if ext else ".jpg" + + +@router.post( + "/generate/upload", + summary="파일 업로드 기반 영상 생성 요청", + description=""" +고객 정보와 이미지 파일을 받아 영상 생성 작업을 시작합니다. + +## 요청 필드 (multipart/form-data) +- **customer_name**: 고객명/가게명 (필수) +- **region**: 지역명 (필수) +- **detail_region_info**: 상세 지역 정보 (선택) +- **attribute**: 음악 속성 정보 JSON 문자열 (필수) +- **images**: 이미지 파일 목록 (필수, 복수 파일) + +## 반환 정보 +- **task_id**: 작업 고유 식별자 (UUID7) +- **status**: 작업 상태 +- **message**: 응답 메시지 +- **uploaded_count**: 업로드된 이미지 개수 + """, + response_model=GenerateUploadResponse, + response_description="생성 작업 시작 결과", + tags=["generate"], +) +async def generate_upload( + customer_name: str = Form(..., description="고객명/가게명"), + region: str = Form(..., description="지역명"), + attribute: str = Form(..., description="음악 속성 정보 (JSON 문자열)"), + images: list[UploadFile] = File(..., description="이미지 파일 목록"), + detail_region_info: str | None = Form(None, description="상세 지역 정보"), + session: AsyncSession = Depends(get_session), +): + """파일 업로드 기반 영상 생성 요청 처리""" + # attribute JSON 파싱 및 검증 + try: + attribute_dict = json.loads(attribute) + attribute_info = AttributeInfo(**attribute_dict) + except json.JSONDecodeError: + raise HTTPException( + status_code=400, detail="attribute는 유효한 JSON 형식이어야 합니다." + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"attribute 검증 실패: {e}") + + # 이미지 파일 검증 + if not images: + raise HTTPException( + status_code=400, detail="최소 1개 이상의 이미지 파일이 필요합니다." + ) + + # UUID7 생성 및 중복 검사 + while True: + task_id = str(uuid7()) + existing = await session.execute( + select(Project).where(Project.task_id == task_id) + ) + if existing.scalar_one_or_none() is None: + break + + # 저장 경로 생성: media/날짜/task_id/ + today = date.today().strftime("%Y%m%d") + upload_dir = MEDIA_ROOT / today / task_id + + # Project 생성 (이미지 정보 제외) + project = Project( + store_name=customer_name, + region=region, + task_id=task_id, + detail_region_info=json.dumps( + { + "detail": detail_region_info, + "attribute": attribute_info.model_dump(), + }, + ensure_ascii=False, + ), + ) + session.add(project) + + # 이미지 파일 저장 및 Image 레코드 생성 + for idx, file in enumerate(images): + # 각 이미지에 고유 UUID7 생성 + img_uuid = str(uuid7()) + ext = _get_file_extension(file.filename) + filename = f"{img_uuid}{ext}" + save_path = upload_dir / filename + + # 파일 저장 + await _save_upload_file(file, save_path) + + # Image 레코드 생성 (독립 테이블, task_id로 연결) + img_url = f"/media/{today}/{task_id}/{filename}" + image = Image( + task_id=task_id, + img_name=file.filename or filename, + img_url=img_url, + img_order=idx, + ) + session.add(image) + + await session.commit() + + return { + "task_id": task_id, + "status": "processing", + "message": "생성 작업이 시작되었습니다.", + "uploaded_count": len(images), + } diff --git a/app/home/api/routers/v1/sample.py b/app/home/api/routers/v1/sample.py new file mode 100644 index 0000000..90ce39f --- /dev/null +++ b/app/home/api/routers/v1/sample.py @@ -0,0 +1,463 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Body, Depends, status +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_session + +router = APIRouter() + + +# ============================================================ +# Pydantic Models for Request/Response +# ============================================================ + + +class SigninRequest(BaseModel): + """회원가입 요청 스키마""" + + email: EmailStr = Field( + ..., description="사용자 이메일 주소", example="user@example.com" + ) + password: str = Field( + ..., min_length=8, description="비밀번호 (최소 8자)", example="password123" + ) + name: str = Field( + ..., min_length=2, max_length=50, description="사용자 이름", example="홍길동" + ) + phone: Optional[str] = Field( + None, + pattern=r"^\d{3}-\d{4}-\d{4}$", + description="전화번호 (형식: 010-1234-5678)", + example="010-1234-5678", + ) + + +class SigninResponse(BaseModel): + """회원가입 응답 스키마""" + + success: bool = Field(..., description="요청 성공 여부") + message: str = Field(..., description="응답 메시지") + user_id: int = Field(..., description="생성된 사용자 ID") + email: EmailStr = Field(..., description="등록된 이메일") + created_at: datetime = Field(..., description="계정 생성 시간") + + +class LoginRequest(BaseModel): + """로그인 요청 스키마""" + + email: EmailStr = Field( + ..., description="사용자 이메일 주소", example="user@example.com" + ) + password: str = Field(..., description="비밀번호", example="password123") + + +class LoginResponse(BaseModel): + """로그인 응답 스키마""" + + success: bool = Field(..., description="로그인 성공 여부") + message: str = Field(..., description="응답 메시지") + access_token: str = Field(..., description="JWT 액세스 토큰") + refresh_token: str = Field(..., description="JWT 리프레시 토큰") + token_type: str = Field(default="bearer", description="토큰 타입") + expires_in: int = Field(..., description="토큰 만료 시간 (초)") + + +class LogoutResponse(BaseModel): + """로그아웃 응답 스키마""" + + success: bool = Field(..., description="로그아웃 성공 여부") + message: str = Field(..., description="응답 메시지") + + +class ProfileResponse(BaseModel): + """프로필 조회 응답 스키마""" + + user_id: int = Field(..., description="사용자 ID") + email: EmailStr = Field(..., description="이메일 주소") + name: str = Field(..., description="사용자 이름") + phone: Optional[str] = Field(None, description="전화번호") + profile_image: Optional[str] = Field(None, description="프로필 이미지 URL") + created_at: datetime = Field(..., description="계정 생성 시간") + last_login: Optional[datetime] = Field(None, description="마지막 로그인 시간") + + +class HomeResponse(BaseModel): + """홈 응답 스키마""" + + message: str = Field(..., description="환영 메시지") + version: str = Field(..., description="API 버전") + status: str = Field(..., description="서비스 상태") + timestamp: datetime = Field(..., description="응답 시간") + + +class ErrorResponse(BaseModel): + """에러 응답 스키마""" + + success: bool = Field(default=False, description="요청 성공 여부") + error_code: str = Field(..., description="에러 코드") + message: str = Field(..., description="에러 메시지") + detail: Optional[str] = Field(None, description="상세 에러 정보") + + +# ============================================================ +# Dummy Data +# ============================================================ + +DUMMY_USER = { + "user_id": 1, + "email": "user@example.com", + "name": "홍길동", + "phone": "010-1234-5678", + "profile_image": "https://example.com/images/profile/default.png", + "created_at": datetime(2024, 1, 15, 10, 30, 0), + "last_login": datetime(2024, 12, 18, 9, 0, 0), +} + +DUMMY_TOKENS = { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzM0NTAwMDAwfQ.dummy_signature", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzM1MDAwMDAwfQ.dummy_refresh", + "token_type": "bearer", + "expires_in": 3600, +} + + +# ============================================================ +# Endpoints +# ============================================================ + + +@router.get( + "/db", + summary="데이터베이스 상태 확인", + description="데이터베이스 연결 상태를 확인합니다. 간단한 쿼리를 실행하여 DB 연결이 정상인지 테스트합니다.", + response_description="데이터베이스 연결 상태 정보", + responses={ + 200: { + "description": "데이터베이스 연결 정상", + "content": { + "application/json": { + "example": { + "status": "healthy", + "database": "connected", + "test_query": 1, + } + } + }, + }, + 500: {"description": "데이터베이스 연결 실패", "model": ErrorResponse}, + }, + tags=["health"], +) +async def db_health_check(session: AsyncSession = Depends(get_session)): + """DB 연결 상태 확인""" + try: + result = await session.execute(text("SELECT 1")) + return { + "status": "healthy", + "database": "connected", + "test_query": result.scalar(), + } + except Exception as e: + return {"status": "unhealthy", "database": "disconnected", "error": str(e)} + + +@router.get( + "/", + summary="홈 엔드포인트", + description="API 서비스의 기본 정보를 반환합니다. 서비스 상태, 버전, 현재 시간 등의 정보를 확인할 수 있습니다.", + response_model=HomeResponse, + response_description="서비스 기본 정보", + responses={ + 200: { + "description": "성공적으로 홈 정보 반환", + "content": { + "application/json": { + "example": { + "message": "CASTAD API 서비스에 오신 것을 환영합니다.", + "version": "0.1.0", + "status": "running", + "timestamp": "2024-12-18T10:00:00", + } + } + }, + } + }, + tags=["home"], +) +async def home() -> HomeResponse: + """홈 페이지 - API 기본 정보 반환""" + return HomeResponse( + message="CASTAD API 서비스에 오신 것을 환영합니다.", + version="0.1.0", + status="running", + timestamp=datetime.now(), + ) + + +@router.post( + "/signin", + summary="회원가입", + description=""" +새로운 사용자 계정을 생성합니다. + +## 요청 필드 +- **email**: 유효한 이메일 주소 (필수) +- **password**: 최소 8자 이상의 비밀번호 (필수) +- **name**: 2~50자 사이의 사용자 이름 (필수) +- **phone**: 전화번호 (선택, 형식: 010-1234-5678) + +## 비밀번호 정책 +- 최소 8자 이상 +- 영문, 숫자 조합 권장 + """, + response_model=SigninResponse, + response_description="회원가입 결과", + status_code=status.HTTP_201_CREATED, + responses={ + 201: {"description": "회원가입 성공", "model": SigninResponse}, + 400: { + "description": "잘못된 요청 (유효성 검사 실패)", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "error_code": "VALIDATION_ERROR", + "message": "입력값이 유효하지 않습니다.", + "detail": "이메일 형식이 올바르지 않습니다.", + } + } + }, + }, + 409: { + "description": "이미 존재하는 이메일", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "error_code": "EMAIL_EXISTS", + "message": "이미 등록된 이메일입니다.", + "detail": None, + } + } + }, + }, + }, + tags=["auth"], +) +async def signin( + request_body: SigninRequest = Body( + ..., + description="회원가입에 필요한 사용자 정보", + openapi_examples={ + "기본 예시": { + "summary": "필수 필드만 입력", + "description": "이메일, 비밀번호, 이름만 입력하는 경우", + "value": { + "email": "newuser@example.com", + "password": "securepass123", + "name": "김철수", + }, + }, + "전체 필드 예시": { + "summary": "모든 필드 입력", + "description": "선택 필드를 포함한 전체 입력", + "value": { + "email": "newuser@example.com", + "password": "securepass123", + "name": "김철수", + "phone": "010-9876-5432", + }, + }, + }, + ), +) -> SigninResponse: + """새로운 사용자 회원가입 처리""" + return SigninResponse( + success=True, + message="회원가입이 완료되었습니다.", + user_id=2, + email=request_body.email, + created_at=datetime.now(), + ) + + +@router.post( + "/login", + summary="로그인", + description=""" +사용자 인증을 수행하고 JWT 토큰을 발급합니다. + +## 인증 방식 +이메일과 비밀번호를 사용한 기본 인증을 수행합니다. +인증 성공 시 액세스 토큰과 리프레시 토큰이 발급됩니다. + +## 토큰 정보 +- **access_token**: API 요청 시 사용 (유효기간: 1시간) +- **refresh_token**: 액세스 토큰 갱신 시 사용 (유효기간: 7일) + """, + response_model=LoginResponse, + response_description="로그인 결과 및 토큰 정보", + responses={ + 200: {"description": "로그인 성공", "model": LoginResponse}, + 401: { + "description": "인증 실패", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "error_code": "INVALID_CREDENTIALS", + "message": "이메일 또는 비밀번호가 올바르지 않습니다.", + "detail": None, + } + } + }, + }, + 403: { + "description": "계정 비활성화", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "error_code": "ACCOUNT_DISABLED", + "message": "비활성화된 계정입니다.", + "detail": "관리자에게 문의하세요.", + } + } + }, + }, + }, + tags=["auth"], +) +async def login( + request_body: LoginRequest = Body( + ..., + description="로그인 인증 정보", + openapi_examples={ + "로그인 예시": { + "summary": "일반 로그인", + "description": "이메일과 비밀번호로 로그인", + "value": {"email": "user@example.com", "password": "password123"}, + } + }, + ), +) -> LoginResponse: + """사용자 로그인 및 토큰 발급""" + return LoginResponse( + success=True, + message="로그인에 성공했습니다.", + access_token=DUMMY_TOKENS["access_token"], + refresh_token=DUMMY_TOKENS["refresh_token"], + token_type=DUMMY_TOKENS["token_type"], + expires_in=DUMMY_TOKENS["expires_in"], + ) + + +@router.post( + "/logout", + summary="로그아웃", + description=""" +현재 세션을 종료하고 토큰을 무효화합니다. + +## 동작 방식 +- 서버 측 토큰 블랙리스트에 현재 토큰 등록 +- 클라이언트 측 토큰 삭제 권장 + +## 주의사항 +로그아웃 후에는 동일한 토큰으로 API 요청이 불가능합니다. + """, + response_model=LogoutResponse, + response_description="로그아웃 결과", + responses={ + 200: {"description": "로그아웃 성공", "model": LogoutResponse}, + 401: { + "description": "인증되지 않은 요청", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "error_code": "UNAUTHORIZED", + "message": "인증이 필요합니다.", + "detail": "유효한 토큰을 제공해주세요.", + } + } + }, + }, + }, + tags=["auth"], +) +async def logout() -> LogoutResponse: + """사용자 로그아웃 처리""" + return LogoutResponse(success=True, message="로그아웃되었습니다.") + + +@router.get( + "/profile", + summary="프로필 조회", + description=""" +현재 로그인한 사용자의 프로필 정보를 조회합니다. + +## 반환 정보 +- 기본 정보: 사용자 ID, 이메일, 이름 +- 연락처 정보: 전화번호 +- 프로필 이미지 URL +- 계정 정보: 생성일, 마지막 로그인 시간 + +## 인증 필요 +이 엔드포인트는 유효한 액세스 토큰이 필요합니다. +Authorization 헤더에 Bearer 토큰을 포함해주세요. + """, + response_model=ProfileResponse, + response_description="사용자 프로필 정보", + responses={ + 200: {"description": "프로필 조회 성공", "model": ProfileResponse}, + 401: { + "description": "인증되지 않은 요청", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "error_code": "UNAUTHORIZED", + "message": "인증이 필요합니다.", + "detail": "유효한 토큰을 제공해주세요.", + } + } + }, + }, + 404: { + "description": "사용자를 찾을 수 없음", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "error_code": "USER_NOT_FOUND", + "message": "사용자를 찾을 수 없습니다.", + "detail": None, + } + } + }, + }, + }, + tags=["user"], +) +async def profile() -> ProfileResponse: + """현재 사용자 프로필 조회""" + return ProfileResponse( + user_id=DUMMY_USER["user_id"], + email=DUMMY_USER["email"], + name=DUMMY_USER["name"], + phone=DUMMY_USER["phone"], + profile_image=DUMMY_USER["profile_image"], + created_at=DUMMY_USER["created_at"], + last_login=DUMMY_USER["last_login"], + ) diff --git a/app/home/models.py b/app/home/models.py index 3986c53..06b8e87 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -9,14 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의 from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import ( - DateTime, - Index, - Integer, - String, - Text, - func, -) +from sqlalchemy import DateTime, Index, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base @@ -53,7 +46,11 @@ class Project(Base): Index("idx_project_task_id", "task_id"), Index("idx_project_store_name", "store_name"), Index("idx_project_region", "region"), - {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, ) id: Mapped[int] = mapped_column( @@ -81,7 +78,6 @@ class Project(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="프로젝트 작업 고유 식별자 (UUID)", ) @@ -152,7 +148,11 @@ class Image(Base): __tablename__ = "image" __table_args__ = ( - {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, ) id: Mapped[int] = mapped_column( @@ -166,14 +166,12 @@ class Image(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="이미지 업로드 작업 고유 식별자 (UUID)", ) img_name: Mapped[str] = mapped_column( String(255), nullable=False, - unique=True, comment="이미지명", ) @@ -183,6 +181,13 @@ class Image(Base): comment="이미지 URL (blob, CDN 경로)", ) + img_order: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="이미지 순서", + ) + created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, @@ -191,7 +196,13 @@ class Image(Base): ) def __repr__(self) -> str: - task_id_str = (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id - img_name_str = (self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name + task_id_str = ( + (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id + ) + img_name_str = ( + (self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name + ) - return f"" + return ( + f"" + ) diff --git a/app/home/schemas/.gitkeep b/app/home/schemas/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/home/schemas/home.py b/app/home/schemas/home.py new file mode 100644 index 0000000..2e5af52 --- /dev/null +++ b/app/home/schemas/home.py @@ -0,0 +1,144 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class AttributeInfo(BaseModel): + """음악 속성 정보""" + + genre: str = Field(..., description="음악 장르") + vocal: str = Field(..., description="보컬 스타일") + tempo: str = Field(..., description="템포") + mood: str = Field(..., description="분위기") + + +class GenerateRequestImg(BaseModel): + """이미지 URL 스키마""" + + url: str = Field(..., description="이미지 URL") + name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") + + +class GenerateRequestInfo(BaseModel): + """생성 요청 정보 스키마 (이미지 제외)""" + + customer_name: str = Field(..., description="고객명/가게명") + region: str = Field(..., description="지역명") + detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") + attribute: AttributeInfo = Field(..., description="음악 속성 정보") + + +class GenerateRequest(GenerateRequestInfo): + """기본 생성 요청 스키마 (이미지 없음, JSON body) + + 이미지 없이 프로젝트 정보만 전달합니다. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "attribute": { + "genre": "K-Pop", + "vocal": "Raspy", + "tempo": "110 BPM", + "mood": "happy", + }, + } + } + ) + + +class GenerateUrlsRequest(GenerateRequestInfo): + """URL 기반 생성 요청 스키마 (JSON body) + + GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "customer_name": "스테이 머뭄", + "region": "군산", + "detail_region_info": "군산 신흥동 말랭이 마을", + "attribute": { + "genre": "K-Pop", + "vocal": "Raspy", + "tempo": "110 BPM", + "mood": "happy", + }, + "images": [ + {"url": "https://example.com/images/image_001.jpg"}, + {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, + ], + } + } + ) + + images: list[GenerateRequestImg] = Field( + ..., description="이미지 URL 목록", min_length=1 + ) + + +class GenerateUploadResponse(BaseModel): + """파일 업로드 기반 생성 응답 스키마""" + + task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") + status: Literal["processing", "completed", "failed"] = Field( + ..., description="작업 상태" + ) + message: str = Field(..., description="응답 메시지") + uploaded_count: int = Field(..., description="업로드된 이미지 개수") + + +class GenerateResponse(BaseModel): + """생성 응답 스키마""" + + task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") + status: Literal["processing", "completed", "failed"] = Field( + ..., description="작업 상태" + ) + message: str = Field(..., description="응답 메시지") + + +class CrawlingRequest(BaseModel): + """크롤링 요청 스키마""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension" + } + } + ) + + url: str = Field(..., description="네이버 지도 장소 URL") + + +class ProcessedInfo(BaseModel): + """가공된 장소 정보 스키마""" + + customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") + region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)") + detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") + + +class CrawlingResponse(BaseModel): + """크롤링 응답 스키마""" + + image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록") + image_count: int = Field(..., description="이미지 개수") + processed_info: Optional[ProcessedInfo] = Field( + None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" + ) + + +class ErrorResponse(BaseModel): + """에러 응답 스키마""" + + success: bool = Field(default=False, description="요청 성공 여부") + error_code: str = Field(..., description="에러 코드") + message: str = Field(..., description="에러 메시지") + detail: Optional[str] = Field(None, description="상세 에러 정보") diff --git a/app/lyric/models.py b/app/lyric/models.py index 17e958c..5eb4517 100644 --- a/app/lyric/models.py +++ b/app/lyric/models.py @@ -1,14 +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, Integer, String, Text, func from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -44,7 +37,11 @@ class Lyric(Base): __tablename__ = "lyric" __table_args__ = ( - {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, ) id: Mapped[int] = mapped_column( @@ -66,7 +63,6 @@ class Lyric(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="가사 생성 작업 고유 식별자 (UUID)", ) @@ -128,4 +124,3 @@ class Lyric(Base): f"status='{self.status}'" f")>" ) - diff --git a/app/song/models.py b/app/song/models.py index e18ccb2..91a3ce4 100644 --- a/app/song/models.py +++ b/app/song/models.py @@ -1,14 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import ( - DateTime, - ForeignKey, - Integer, - String, - Text, - func, -) +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base @@ -45,7 +38,11 @@ class Song(Base): __tablename__ = "song" __table_args__ = ( - {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, ) id: Mapped[int] = mapped_column( @@ -75,7 +72,6 @@ class Song(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="노래 생성 작업 고유 식별자 (UUID)", ) @@ -141,4 +137,3 @@ class Song(Base): f"status='{self.status}'" f")>" ) - diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py new file mode 100644 index 0000000..db164d8 --- /dev/null +++ b/app/utils/nvMapScraper.py @@ -0,0 +1,112 @@ +import json +import re + +import aiohttp + +from config import crawler_settings + + +class GraphQLException(Exception): + pass + + +class NvMapScraper: + GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql" + + OVERVIEW_QUERY: str = """ +query getAccommodation($id: String!, $deviceType: String) { + business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) { + base { + id + name + category + roadAddress + address + phone + virtualPhone + microReviews + conveniences + visitorReviewsTotal + } + images { images { origin url } } + cpImages(source: [ugcImage]) { images { origin url } } + } +}""" + + DEFAULT_HEADERS: dict = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Referer": "https://map.naver.com/", + "Origin": "https://map.naver.com", + "Content-Type": "application/json", + } + + def __init__(self, url: str, cookies: str | None = None): + self.url = url + self.cookies = ( + cookies if cookies is not None else crawler_settings.NAVER_COOKIES + ) + self.scrap_type: str | None = None + self.rawdata: dict | None = None + self.image_link_list: list[str] | None = None + self.base_info: dict | None = None + + def _get_request_headers(self) -> dict: + headers = self.DEFAULT_HEADERS.copy() + if self.cookies: + headers["Cookie"] = self.cookies + return headers + + def parse_url(self) -> str: + place_pattern = r"/place/(\d+)" + match = re.search(place_pattern, self.url) + if not match: + raise GraphQLException("Failed to parse place ID from URL") + return match[1] + + async def scrap(self): + try: + place_id = self.parse_url() + data = await self._call_get_accommodation(place_id) + self.rawdata = data + self.image_link_list = [ + nv_image["origin"] + for nv_image in data["data"]["business"]["images"]["images"] + ] + self.base_info = data["data"]["business"]["base"] + self.scrap_type = "GraphQL" + + except GraphQLException: + print("fallback") + self.scrap_type = "Playwright" + pass # 나중에 pw 이용한 crawling으로 fallback 추가 + + return + + async def _call_get_accommodation(self, place_id: str) -> dict: + payload = { + "operationName": "getAccommodation", + "variables": {"id": place_id, "deviceType": "pc"}, + "query": self.OVERVIEW_QUERY, + } + json_payload = json.dumps(payload) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers() + ) as response: + if response.status == 200: + return await response.json() + else: + print("실패 상태 코드:", response.status) + raise GraphQLException( + f"Request failed with status {response.status}" + ) + + +# if __name__ == "__main__": +# url = "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5×tamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension" +# scraper = NvMapScraper(url) +# asyncio.run(scraper.scrap()) +# print(scraper.image_link_list) +# print(len(scraper.image_link_list) if scraper.image_link_list else 0) +# print(scraper.base_info) diff --git a/app/video/models.py b/app/video/models.py index d5619c9..35ca01e 100644 --- a/app/video/models.py +++ b/app/video/models.py @@ -1,13 +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, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database.session import Base @@ -43,7 +37,11 @@ class Video(Base): __tablename__ = "video" __table_args__ = ( - {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, ) id: Mapped[int] = mapped_column( @@ -81,7 +79,6 @@ class Video(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, index=True, comment="영상 생성 작업 고유 식별자 (UUID)", ) diff --git a/config.py b/config.py index b40c4dc..751814b 100644 --- a/config.py +++ b/config.py @@ -116,9 +116,16 @@ class NotificationSettings(BaseSettings): model_config = _base_config +class CrawlerSettings(BaseSettings): + NAVER_COOKIES: str = Field(default="") + + model_config = _base_config + + prj_settings = ProjectSettings() apikey_settings = APIKeySettings() db_settings = DatabaseSettings() security_settings = SecuritySettings() notification_settings = NotificationSettings() cors_settings = CORSSettings() +crawler_settings = CrawlerSettings() diff --git a/docs/reference/ConfigDict.md b/docs/reference/ConfigDict.md new file mode 100644 index 0000000..2ad9af7 --- /dev/null +++ b/docs/reference/ConfigDict.md @@ -0,0 +1,382 @@ +# Pydantic ConfigDict 사용 매뉴얼 + +## 개요 + +Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다. + +> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다. + +## 기본 사용법 + +```python +from pydantic import BaseModel, ConfigDict + +class MyModel(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + strict=True + ) + + name: str + age: int +``` + +## 설정 옵션 전체 목록 + +### 문자열 처리 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 | +| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 | +| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 | +| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 | +| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 | + +**예시:** +```python +class UserInput(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + str_to_lower=True, + str_min_length=1, + str_max_length=100 + ) + + username: str + +user = UserInput(username=" HELLO ") +print(user.username) # "hello" +``` + +### 유효성 검사 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) | +| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 | +| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 | +| `validate_return` | `bool` | `False` | 반환값 유효성 검사 | +| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 | +| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 | + +**예시 - strict 모드:** +```python +class StrictModel(BaseModel): + model_config = ConfigDict(strict=True) + + count: int + +# strict=False (기본값): "123" -> 123 자동 변환 +# strict=True: "123" 입력 시 ValidationError 발생 +``` + +**예시 - validate_assignment:** +```python +class User(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + age: int + +user = User(age=25) +user.age = "invalid" # ValidationError 발생 +``` + +### Extra 필드 처리 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 | + +**값 설명:** +- `'ignore'`: 추가 필드 무시 (기본값) +- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장 +- `'forbid'`: 추가 필드 입력 시 에러 발생 + +**예시:** +```python +class AllowExtra(BaseModel): + model_config = ConfigDict(extra='allow') + + name: str + +data = AllowExtra(name="John", unknown_field="value") +print(data.__pydantic_extra__) # {'unknown_field': 'value'} + +class ForbidExtra(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: str + +ForbidExtra(name="John", unknown="value") # ValidationError 발생 +``` + +### 불변성 (Immutability) + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 | + +**예시:** +```python +class ImmutableUser(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str + age: int + +user = ImmutableUser(name="John", age=30) +user.age = 31 # 에러 발생: Instance is frozen + +# frozen=True이면 해시 가능 +users_set = {user} # 정상 작동 +``` + +### 별칭 (Alias) 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) | +| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 | +| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 | +| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 | +| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 | +| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 | + +**예시:** +```python +from pydantic import Field + +class APIResponse(BaseModel): + model_config = ConfigDict( + validate_by_alias=True, + validate_by_name=True, + serialize_by_alias=True + ) + + user_name: str = Field(alias="userName") + +# 둘 다 가능 +response1 = APIResponse(userName="John") +response2 = APIResponse(user_name="John") + +print(response1.model_dump(by_alias=True)) # {"userName": "John"} +``` + +**예시 - alias_generator:** +```python +def to_camel(name: str) -> str: + parts = name.split('_') + return parts[0] + ''.join(word.capitalize() for word in parts[1:]) + +class CamelModel(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + serialize_by_alias=True + ) + + first_name: str + last_name: str + +data = CamelModel(firstName="John", lastName="Doe") +print(data.model_dump(by_alias=True)) +# {"firstName": "John", "lastName": "Doe"} +``` + +### JSON 스키마 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `title` | `str \| None` | `None` | JSON 스키마 타이틀 | +| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 | +| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 | +| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 | + +**예시 - json_schema_extra:** +```python +class Product(BaseModel): + model_config = ConfigDict( + title="상품 정보", + json_schema_extra={ + "example": { + "name": "노트북", + "price": 1500000 + }, + "description": "상품 데이터를 나타내는 모델" + } + ) + + name: str + price: int + +# OpenAPI/Swagger 문서에 예시가 표시됨 +``` + +**예시 - Callable json_schema_extra:** +```python +def add_examples(schema: dict) -> dict: + schema["examples"] = [ + {"name": "예시1", "value": 100}, + {"name": "예시2", "value": 200} + ] + return schema + +class DynamicSchema(BaseModel): + model_config = ConfigDict(json_schema_extra=add_examples) + + name: str + value: int +``` + +### ORM/속성 모드 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) | + +**예시:** +```python +class UserORM: + def __init__(self, name: str, age: int): + self.name = name + self.age = age + +class UserModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + age: int + +orm_user = UserORM(name="John", age=30) +pydantic_user = UserModel.model_validate(orm_user) +print(pydantic_user) # name='John' age=30 +``` + +### Enum 처리 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 | + +**예시:** +```python +from enum import Enum + +class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + +class User(BaseModel): + model_config = ConfigDict(use_enum_values=True) + + status: Status + +user = User(status=Status.ACTIVE) +print(user.status) # "active" (문자열) +print(type(user.status)) # + +# use_enum_values=False (기본값)이면 +# user.status는 Status.ACTIVE (Enum 객체) +``` + +### 직렬화 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 | +| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 | +| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 | + +### 숫자/Float 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 | +| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 | + +### 기타 설정 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 | +| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 | +| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 | +| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 | +| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 | +| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 | + +## 설정 상속 + +자식 모델은 부모 모델의 `model_config`를 상속받습니다. + +```python +class ParentModel(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + extra='allow' + ) + + name: str + +class ChildModel(ParentModel): + model_config = ConfigDict( + frozen=True # 부모 설정 + frozen=True + ) + + age: int + +# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True +``` + +## FastAPI와 함께 사용 + +FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다. + +```python +from fastapi import FastAPI +from pydantic import BaseModel, ConfigDict, Field + +app = FastAPI() + +class CreateUserRequest(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + json_schema_extra={ + "example": { + "username": "johndoe", + "email": "john@example.com" + } + } + ) + + username: str = Field(..., min_length=3, max_length=50) + email: str + +class UserResponse(BaseModel): + model_config = ConfigDict( + from_attributes=True, # ORM 객체에서 변환 가능 + serialize_by_alias=True + ) + + id: int + user_name: str = Field(alias="userName") + +@app.post("/users", response_model=UserResponse) +async def create_user(user: CreateUserRequest): + # user.username은 자동으로 공백이 제거됨 + ... +``` + +## 주의사항 + +1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요. + +2. **populate_by_name은 deprecated**: `validate_by_alias`와 `validate_by_name`을 함께 사용하세요. + +3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요. + +## 참고 자료 + +- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/) +- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/) +- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/) diff --git a/docs/reference/fastapi_sqlalchemy_guide.md b/docs/reference/fastapi_sqlalchemy_guide.md new file mode 100644 index 0000000..511a324 --- /dev/null +++ b/docs/reference/fastapi_sqlalchemy_guide.md @@ -0,0 +1,2965 @@ +# FastAPI + SQLAlchemy ORM 완벽 실무 가이드 + +> SQLAlchemy 2.0+ / FastAPI 0.100+ / Python 3.10+ 기준 + +--- + +## 목차 + +1. [기본 설정](#1-기본-설정) +2. [모델 정의](#2-모델-정의) +3. [CRUD 기본 작업](#3-crud-기본-작업) +4. [조회 쿼리 심화](#4-조회-쿼리-심화) +5. [필터링과 조건](#5-필터링과-조건) +6. [정렬, 페이징, 제한](#6-정렬-페이징-제한) +7. [집계 함수 (Aggregation)](#7-집계-함수-aggregation) +8. [JOIN 쿼리](#8-join-쿼리) +9. [서브쿼리 (Subquery)](#9-서브쿼리-subquery) +10. [집합 연산 (Union, Intersect, Except)](#10-집합-연산-union-intersect-except) +11. [고급 표현식](#11-고급-표현식) +12. [Relationship과 Eager Loading](#12-relationship과-eager-loading) +13. [트랜잭션 관리](#13-트랜잭션-관리) +14. [FastAPI 통합 패턴](#14-fastapi-통합-패턴) +15. [성능 최적화](#15-성능-최적화) +16. [실무 레시피](#16-실무-레시피) + +--- + +## 1. 기본 설정 + +### 1.1 프로젝트 구조 + +``` +project/ +├── app/ +│ ├── __init__.py +│ ├── main.py +│ ├── config.py +│ ├── database.py +│ ├── models/ +│ │ ├── __init__.py +│ │ ├── base.py +│ │ ├── user.py +│ │ └── product.py +│ ├── schemas/ +│ │ ├── __init__.py +│ │ ├── user.py +│ │ └── product.py +│ ├── repositories/ +│ │ ├── __init__.py +│ │ └── user.py +│ ├── services/ +│ │ ├── __init__.py +│ │ └── user.py +│ └── routers/ +│ ├── __init__.py +│ └── user.py +├── alembic/ +├── tests/ +├── alembic.ini +├── requirements.txt +└── .env +``` + +### 1.2 Database 설정 + +```python +# app/config.py +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + DATABASE_URL: str = "mysql+pymysql://user:pass@localhost:3306/dbname" + DATABASE_ECHO: bool = False # SQL 로그 출력 + DATABASE_POOL_SIZE: int = 5 + DATABASE_MAX_OVERFLOW: int = 10 + + class Config: + env_file = ".env" + + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +```python +# app/database.py +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator + +from app.config import get_settings + +settings = get_settings() + +# Engine 생성 +engine = create_engine( + settings.DATABASE_URL, + echo=settings.DATABASE_ECHO, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_pre_ping=True, # 연결 유효성 검사 + pool_recycle=3600, # 1시간마다 연결 재생성 +) + +# Session Factory +SessionLocal = sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, # commit 후에도 객체 접근 가능 +) + + +# Dependency +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() +``` + +### 1.3 Base 모델 정의 + +```python +# app/models/base.py +from datetime import datetime +from sqlalchemy import func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """모든 모델의 기본 클래스""" + pass + + +class TimestampMixin: + """생성/수정 시간 믹스인""" + created_at: Mapped[datetime] = mapped_column( + default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SoftDeleteMixin: + """소프트 삭제 믹스인""" + is_deleted: Mapped[bool] = mapped_column(default=False) + deleted_at: Mapped[datetime | None] = mapped_column(default=None) +``` + +--- + +## 2. 모델 정의 + +### 2.1 기본 모델 + +```python +# app/models/user.py +from typing import List, Optional +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class User(TimestampMixin, Base): + __tablename__ = "user" + + # Primary Key + id: Mapped[int] = mapped_column(primary_key=True) + + # 필수 필드 + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + username: Mapped[str] = mapped_column(String(50), unique=True) + hashed_password: Mapped[str] = mapped_column(String(255)) + + # 선택 필드 (nullable) + full_name: Mapped[Optional[str]] = mapped_column(String(100)) + bio: Mapped[Optional[str]] = mapped_column(Text) + + # 기본값이 있는 필드 + is_active: Mapped[bool] = mapped_column(default=True) + is_superuser: Mapped[bool] = mapped_column(default=False) + login_count: Mapped[int] = mapped_column(default=0) + + # Relationships + posts: Mapped[List["Post"]] = relationship( + "Post", + back_populates="author", + cascade="all, delete-orphan", + lazy="selectin", + ) + + profile: Mapped[Optional["Profile"]] = relationship( + "Profile", + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + ) + + # 테이블 설정 + __table_args__ = ( + Index("idx_user_email_active", "email", "is_active"), + {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, + ) + + def __repr__(self) -> str: + return f"" +``` + +### 2.2 관계가 있는 모델들 + +```python +# app/models/post.py +from typing import List, Optional +from sqlalchemy import String, Text, ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class Post(TimestampMixin, Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text) + view_count: Mapped[int] = mapped_column(default=0) + is_published: Mapped[bool] = mapped_column(default=False) + + # Foreign Key + author_id: Mapped[int] = mapped_column( + ForeignKey("user.id", ondelete="CASCADE"), + index=True, + ) + category_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("category.id", ondelete="SET NULL"), + nullable=True, + ) + + # Relationships + author: Mapped["User"] = relationship("User", back_populates="posts") + category: Mapped[Optional["Category"]] = relationship("Category", back_populates="posts") + comments: Mapped[List["Comment"]] = relationship( + "Comment", + back_populates="post", + cascade="all, delete-orphan", + order_by="Comment.created_at.desc()", + ) + + # N:M 관계 + tags: Mapped[List["Tag"]] = relationship( + "Tag", + secondary="post_tag", + back_populates="posts", + ) + + __table_args__ = ( + Index("idx_post_author_published", "author_id", "is_published"), + ) + + +class Category(Base): + __tablename__ = "category" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True) + description: Mapped[Optional[str]] = mapped_column(Text) + + posts: Mapped[List["Post"]] = relationship("Post", back_populates="category") + + +class Tag(Base): + __tablename__ = "tag" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(30), unique=True) + + posts: Mapped[List["Post"]] = relationship( + "Post", + secondary="post_tag", + back_populates="tags", + ) + + +# N:M 중간 테이블 +from sqlalchemy import Table, Column, Integer, ForeignKey +from app.models.base import Base + +post_tag = Table( + "post_tag", + Base.metadata, + Column("post_id", Integer, ForeignKey("post.id", ondelete="CASCADE"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True), +) +``` + +--- + +## 3. CRUD 기본 작업 + +### 3.1 Create (생성) + +```python +from sqlalchemy import select +from sqlalchemy.orm import Session +from app.models.user import User + + +# ───────────────────────────────────────────────────── +# 단일 레코드 생성 +# ───────────────────────────────────────────────────── + +def create_user(db: Session, email: str, username: str, password: str) -> User: + user = User( + email=email, + username=username, + hashed_password=password, + ) + db.add(user) + db.commit() + db.refresh(user) # DB에서 생성된 값(id, created_at 등) 로드 + return user + + +# ───────────────────────────────────────────────────── +# 여러 레코드 한번에 생성 +# ───────────────────────────────────────────────────── + +def create_users_bulk(db: Session, users_data: list[dict]) -> list[User]: + users = [User(**data) for data in users_data] + db.add_all(users) + db.commit() + + # 각 객체 refresh + for user in users: + db.refresh(user) + + return users + + +# ───────────────────────────────────────────────────── +# 관계와 함께 생성 +# ───────────────────────────────────────────────────── + +def create_post_with_tags( + db: Session, + title: str, + content: str, + author_id: int, + tag_names: list[str] +) -> Post: + # 기존 태그 조회 또는 생성 + tags = [] + for name in tag_names: + tag = db.scalar(select(Tag).where(Tag.name == name)) + if not tag: + tag = Tag(name=name) + tags.append(tag) + + post = Post( + title=title, + content=content, + author_id=author_id, + tags=tags, + ) + db.add(post) + db.commit() + db.refresh(post) + return post + + +# ───────────────────────────────────────────────────── +# Insert ... ON DUPLICATE KEY UPDATE (Upsert) +# ───────────────────────────────────────────────────── + +from sqlalchemy.dialects.mysql import insert as mysql_insert + +def upsert_user(db: Session, email: str, username: str) -> None: + stmt = mysql_insert(User).values( + email=email, + username=username, + ) + stmt = stmt.on_duplicate_key_update( + username=stmt.inserted.username, + updated_at=func.now(), + ) + db.execute(stmt) + db.commit() + + +# PostgreSQL의 경우 +from sqlalchemy.dialects.postgresql import insert as pg_insert + +def upsert_user_pg(db: Session, email: str, username: str) -> None: + stmt = pg_insert(User).values(email=email, username=username) + stmt = stmt.on_conflict_do_update( + index_elements=["email"], + set_={"username": username}, + ) + db.execute(stmt) + db.commit() +``` + +### 3.2 Read (조회) + +```python +from sqlalchemy import select +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# Primary Key로 조회 +# ───────────────────────────────────────────────────── + +def get_user_by_id(db: Session, user_id: int) -> User | None: + return db.get(User, user_id) + + +# ───────────────────────────────────────────────────── +# 단일 레코드 조회 (조건) +# ───────────────────────────────────────────────────── + +def get_user_by_email(db: Session, email: str) -> User | None: + stmt = select(User).where(User.email == email) + return db.scalar(stmt) + + +# scalar(): 단일 값 반환 (없으면 None) +# scalars(): 여러 값의 ScalarResult 반환 +# one(): 정확히 1개 (0개 또는 2개 이상이면 에러) +# one_or_none(): 0개면 None, 1개면 반환, 2개 이상이면 에러 +# first(): 첫 번째 결과 (없으면 None) + + +# ───────────────────────────────────────────────────── +# 여러 레코드 조회 +# ───────────────────────────────────────────────────── + +def get_all_users(db: Session) -> list[User]: + stmt = select(User) + return list(db.scalars(stmt).all()) + + +def get_active_users(db: Session) -> list[User]: + stmt = select(User).where(User.is_active == True) + return list(db.scalars(stmt).all()) + + +# ───────────────────────────────────────────────────── +# 특정 컬럼만 조회 +# ───────────────────────────────────────────────────── + +def get_user_emails(db: Session) -> list[str]: + stmt = select(User.email) + return list(db.scalars(stmt).all()) + + +def get_user_summary(db: Session) -> list[tuple]: + stmt = select(User.id, User.email, User.username) + return list(db.execute(stmt).all()) + + +# ───────────────────────────────────────────────────── +# 존재 여부 확인 +# ───────────────────────────────────────────────────── + +def user_exists(db: Session, email: str) -> bool: + stmt = select(User.id).where(User.email == email) + return db.scalar(stmt) is not None + + +# 또는 exists() 사용 +from sqlalchemy import exists + +def user_exists_v2(db: Session, email: str) -> bool: + stmt = select(exists().where(User.email == email)) + return db.scalar(stmt) +``` + +### 3.3 Update (수정) + +```python +from sqlalchemy import update +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# 단일 레코드 수정 (ORM 방식) +# ───────────────────────────────────────────────────── + +def update_user(db: Session, user_id: int, **kwargs) -> User | None: + user = db.get(User, user_id) + if not user: + return None + + for key, value in kwargs.items(): + if hasattr(user, key): + setattr(user, key, value) + + db.commit() + db.refresh(user) + return user + + +# ───────────────────────────────────────────────────── +# Bulk Update (Core 방식) - 더 효율적 +# ───────────────────────────────────────────────────── + +def deactivate_users(db: Session, user_ids: list[int]) -> int: + stmt = ( + update(User) + .where(User.id.in_(user_ids)) + .values(is_active=False) + ) + result = db.execute(stmt) + db.commit() + return result.rowcount # 영향받은 행 수 + + +# ───────────────────────────────────────────────────── +# 조건부 Update +# ───────────────────────────────────────────────────── + +def increment_login_count(db: Session, user_id: int) -> None: + stmt = ( + update(User) + .where(User.id == user_id) + .values(login_count=User.login_count + 1) + ) + db.execute(stmt) + db.commit() + + +# ───────────────────────────────────────────────────── +# CASE를 사용한 조건부 Update +# ───────────────────────────────────────────────────── + +from sqlalchemy import case + +def update_user_levels(db: Session) -> None: + stmt = ( + update(User) + .values( + level=case( + (User.login_count >= 100, "gold"), + (User.login_count >= 50, "silver"), + else_="bronze", + ) + ) + ) + db.execute(stmt) + db.commit() +``` + +### 3.4 Delete (삭제) + +```python +from sqlalchemy import delete +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# 단일 레코드 삭제 (ORM 방식) +# ───────────────────────────────────────────────────── + +def delete_user(db: Session, user_id: int) -> bool: + user = db.get(User, user_id) + if not user: + return False + + db.delete(user) # cascade 설정에 따라 관련 데이터도 삭제 + db.commit() + return True + + +# ───────────────────────────────────────────────────── +# Bulk Delete (Core 방식) - 더 효율적 +# ───────────────────────────────────────────────────── + +def delete_inactive_users(db: Session) -> int: + stmt = delete(User).where(User.is_active == False) + result = db.execute(stmt) + db.commit() + return result.rowcount + + +# ───────────────────────────────────────────────────── +# Soft Delete +# ───────────────────────────────────────────────────── + +from datetime import datetime + +def soft_delete_user(db: Session, user_id: int) -> bool: + stmt = ( + update(User) + .where(User.id == user_id) + .values(is_deleted=True, deleted_at=datetime.utcnow()) + ) + result = db.execute(stmt) + db.commit() + return result.rowcount > 0 + + +# ───────────────────────────────────────────────────── +# 관계 데이터 삭제 +# ───────────────────────────────────────────────────── + +def remove_tag_from_post(db: Session, post_id: int, tag_id: int) -> None: + post = db.get(Post, post_id) + tag = db.get(Tag, tag_id) + + if post and tag and tag in post.tags: + post.tags.remove(tag) + db.commit() +``` + +--- + +## 4. 조회 쿼리 심화 + +### 4.1 select() 기본 사용법 + +```python +from sqlalchemy import select + + +# ───────────────────────────────────────────────────── +# 전체 모델 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User) +# SELECT user.id, user.email, user.username, ... FROM user + + +# ───────────────────────────────────────────────────── +# 특정 컬럼만 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User.id, User.email) +# SELECT user.id, user.email FROM user + + +# ───────────────────────────────────────────────────── +# 컬럼 별칭 (alias) +# ───────────────────────────────────────────────────── + +stmt = select(User.email.label("user_email")) +# SELECT user.email AS user_email FROM user + + +# ───────────────────────────────────────────────────── +# DISTINCT +# ───────────────────────────────────────────────────── + +stmt = select(User.category_id).distinct() +# SELECT DISTINCT user.category_id FROM user + + +# ───────────────────────────────────────────────────── +# 여러 테이블에서 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post) +# SELECT user.*, post.* FROM user JOIN post ON ... + + +stmt = select(User.email, Post.title).join(Post) +# SELECT user.email, post.title FROM user JOIN post ON ... +``` + +### 4.2 실행 메서드 비교 + +```python +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# execute() - Row 객체 반환 +# ───────────────────────────────────────────────────── + +stmt = select(User.id, User.email) +result = db.execute(stmt) + +for row in result: + print(row.id, row.email) # Row 객체 + print(row[0], row[1]) # 인덱스 접근 + print(row._mapping) # dict-like 접근 + + +# ───────────────────────────────────────────────────── +# scalars() - 첫 번째 컬럼만 반환 +# ───────────────────────────────────────────────────── + +stmt = select(User) +users = db.scalars(stmt).all() # list[User] + +stmt = select(User.email) +emails = db.scalars(stmt).all() # list[str] + + +# ───────────────────────────────────────────────────── +# scalar() - 단일 값 반환 +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.id == 1) +user = db.scalar(stmt) # User | None + +stmt = select(func.count(User.id)) +count = db.scalar(stmt) # int + + +# ───────────────────────────────────────────────────── +# 결과 처리 메서드 +# ───────────────────────────────────────────────────── + +result = db.scalars(stmt) + +result.all() # 모든 결과를 리스트로 +result.first() # 첫 번째 결과 (없으면 None) +result.one() # 정확히 1개 (아니면 예외) +result.one_or_none()# 0-1개 (2개 이상이면 예외) +result.fetchmany(5) # 5개만 가져오기 +result.unique() # 중복 제거 (relationship 로딩 시 필요) +``` + +--- + +## 5. 필터링과 조건 + +### 5.1 기본 비교 연산자 + +```python +from sqlalchemy import select, and_, or_, not_ + + +# ───────────────────────────────────────────────────── +# 동등 비교 +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email == "test@example.com") +stmt = select(User).where(User.is_active == True) +stmt = select(User).where(User.category_id == None) # IS NULL + + +# ───────────────────────────────────────────────────── +# 부등 비교 +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.age != 30) +stmt = select(User).where(User.age > 18) +stmt = select(User).where(User.age >= 18) +stmt = select(User).where(User.age < 65) +stmt = select(User).where(User.age <= 65) + + +# ───────────────────────────────────────────────────── +# BETWEEN +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.age.between(18, 65)) +# WHERE age BETWEEN 18 AND 65 + + +# ───────────────────────────────────────────────────── +# IN / NOT IN +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.status.in_(["active", "pending"])) +stmt = select(User).where(User.id.in_([1, 2, 3, 4, 5])) +stmt = select(User).where(User.status.not_in(["deleted", "banned"])) + + +# ───────────────────────────────────────────────────── +# IS NULL / IS NOT NULL +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.deleted_at.is_(None)) # IS NULL +stmt = select(User).where(User.deleted_at.is_not(None)) # IS NOT NULL +stmt = select(User).where(User.bio.isnot(None)) # 또 다른 방법 +``` + +### 5.2 문자열 연산 + +```python +# ───────────────────────────────────────────────────── +# LIKE / ILIKE +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email.like("%@gmail.com")) +stmt = select(User).where(User.username.like("john%")) +stmt = select(User).where(User.name.like("%홍길%")) + +# 대소문자 무시 (PostgreSQL) +stmt = select(User).where(User.email.ilike("%@GMAIL.COM")) + + +# ───────────────────────────────────────────────────── +# CONTAINS (LIKE '%value%') +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.bio.contains("python")) +# WHERE bio LIKE '%python%' + + +# ───────────────────────────────────────────────────── +# STARTSWITH / ENDSWITH +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email.startswith("admin")) +# WHERE email LIKE 'admin%' + +stmt = select(User).where(User.email.endswith("@company.com")) +# WHERE email LIKE '%@company.com' + + +# ───────────────────────────────────────────────────── +# 정규표현식 (DB 지원 필요) +# ───────────────────────────────────────────────────── + +stmt = select(User).where(User.email.regexp_match(r"^[a-z]+@")) +``` + +### 5.3 논리 연산자 + +```python +from sqlalchemy import and_, or_, not_ + + +# ───────────────────────────────────────────────────── +# AND +# ───────────────────────────────────────────────────── + +# 방법 1: and_() 함수 +stmt = select(User).where( + and_( + User.is_active == True, + User.age >= 18, + ) +) + +# 방법 2: where() 체이닝 (암묵적 AND) +stmt = select(User).where(User.is_active == True).where(User.age >= 18) + +# 방법 3: 콤마로 구분 +stmt = select(User).where(User.is_active == True, User.age >= 18) + + +# ───────────────────────────────────────────────────── +# OR +# ───────────────────────────────────────────────────── + +stmt = select(User).where( + or_( + User.role == "admin", + User.role == "moderator", + ) +) + + +# ───────────────────────────────────────────────────── +# NOT +# ───────────────────────────────────────────────────── + +stmt = select(User).where(not_(User.is_deleted == True)) +stmt = select(User).where(~(User.is_deleted == True)) # ~ 연산자 + + +# ───────────────────────────────────────────────────── +# 복합 조건 +# ───────────────────────────────────────────────────── + +stmt = select(User).where( + and_( + User.is_active == True, + or_( + User.role == "admin", + User.age >= 21, + ), + not_(User.is_deleted == True), + ) +) +# WHERE is_active = true +# AND (role = 'admin' OR age >= 21) +# AND NOT is_deleted = true +``` + +### 5.4 동적 필터링 + +```python +from typing import Optional + + +def search_users( + db: Session, + email: Optional[str] = None, + username: Optional[str] = None, + is_active: Optional[bool] = None, + min_age: Optional[int] = None, + max_age: Optional[int] = None, +) -> list[User]: + stmt = select(User) + + # 동적으로 조건 추가 + if email: + stmt = stmt.where(User.email.contains(email)) + + if username: + stmt = stmt.where(User.username.like(f"%{username}%")) + + if is_active is not None: + stmt = stmt.where(User.is_active == is_active) + + if min_age is not None: + stmt = stmt.where(User.age >= min_age) + + if max_age is not None: + stmt = stmt.where(User.age <= max_age) + + return list(db.scalars(stmt).all()) + + +# 사용 +users = search_users(db, email="gmail", is_active=True, min_age=18) +``` + +--- + +## 6. 정렬, 페이징, 제한 + +### 6.1 정렬 (ORDER BY) + +```python +from sqlalchemy import select, asc, desc + + +# ───────────────────────────────────────────────────── +# 기본 정렬 +# ───────────────────────────────────────────────────── + +# 오름차순 (기본) +stmt = select(User).order_by(User.created_at) +stmt = select(User).order_by(asc(User.created_at)) + +# 내림차순 +stmt = select(User).order_by(desc(User.created_at)) +stmt = select(User).order_by(User.created_at.desc()) + + +# ───────────────────────────────────────────────────── +# 다중 정렬 +# ───────────────────────────────────────────────────── + +stmt = select(User).order_by( + User.is_active.desc(), + User.created_at.desc(), +) +# ORDER BY is_active DESC, created_at DESC + + +# ───────────────────────────────────────────────────── +# NULL 처리 +# ───────────────────────────────────────────────────── + +stmt = select(User).order_by(User.last_login.desc().nullslast()) +# NULL 값을 마지막에 + +stmt = select(User).order_by(User.last_login.asc().nullsfirst()) +# NULL 값을 처음에 +``` + +### 6.2 제한과 오프셋 (LIMIT, OFFSET) + +```python +# ───────────────────────────────────────────────────── +# LIMIT +# ───────────────────────────────────────────────────── + +stmt = select(User).limit(10) +# SELECT ... FROM user LIMIT 10 + + +# ───────────────────────────────────────────────────── +# OFFSET +# ───────────────────────────────────────────────────── + +stmt = select(User).offset(20).limit(10) +# SELECT ... FROM user LIMIT 10 OFFSET 20 + + +# ───────────────────────────────────────────────────── +# 슬라이스 문법 +# ───────────────────────────────────────────────────── + +stmt = select(User).slice(20, 30) # offset=20, limit=10과 동일 +``` + +### 6.3 페이지네이션 구현 + +```python +from typing import TypeVar, Generic +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.orm import Session + +T = TypeVar("T") + + +class PaginatedResult(BaseModel, Generic[T]): + items: list[T] + total: int + page: int + page_size: int + total_pages: int + has_next: bool + has_prev: bool + + +def paginate( + db: Session, + stmt, + page: int = 1, + page_size: int = 20, +) -> dict: + # 페이지 유효성 검사 + page = max(1, page) + page_size = min(max(1, page_size), 100) # 최대 100개 + + # 전체 개수 조회 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = db.scalar(count_stmt) + + # 페이지네이션 적용 + offset = (page - 1) * page_size + paginated_stmt = stmt.offset(offset).limit(page_size) + items = list(db.scalars(paginated_stmt).all()) + + total_pages = (total + page_size - 1) // page_size + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + } + + +# 사용 예시 +def get_users_paginated( + db: Session, + page: int = 1, + page_size: int = 20, + is_active: bool | None = None, +) -> dict: + stmt = select(User).order_by(User.created_at.desc()) + + if is_active is not None: + stmt = stmt.where(User.is_active == is_active) + + return paginate(db, stmt, page, page_size) + + +# FastAPI 엔드포인트 +@router.get("/users") +def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + is_active: bool | None = None, + db: Session = Depends(get_db), +): + return get_users_paginated(db, page, page_size, is_active) +``` + +### 6.4 커서 기반 페이지네이션 + +```python +from datetime import datetime +from typing import Optional + + +def get_users_cursor( + db: Session, + limit: int = 20, + cursor: Optional[datetime] = None, +) -> dict: + stmt = select(User).order_by(User.created_at.desc()) + + if cursor: + stmt = stmt.where(User.created_at < cursor) + + stmt = stmt.limit(limit + 1) # 다음 페이지 확인용으로 1개 더 + + items = list(db.scalars(stmt).all()) + + has_next = len(items) > limit + if has_next: + items = items[:limit] + + next_cursor = items[-1].created_at if items and has_next else None + + return { + "items": items, + "next_cursor": next_cursor, + "has_next": has_next, + } + + +# 복합 커서 (동일 시간 처리) +def get_users_cursor_v2( + db: Session, + limit: int = 20, + cursor_time: Optional[datetime] = None, + cursor_id: Optional[int] = None, +) -> dict: + stmt = select(User).order_by( + User.created_at.desc(), + User.id.desc(), + ) + + if cursor_time and cursor_id: + stmt = stmt.where( + or_( + User.created_at < cursor_time, + and_( + User.created_at == cursor_time, + User.id < cursor_id, + ), + ) + ) + + stmt = stmt.limit(limit + 1) + items = list(db.scalars(stmt).all()) + + has_next = len(items) > limit + if has_next: + items = items[:limit] + + return { + "items": items, + "next_cursor": { + "time": items[-1].created_at, + "id": items[-1].id, + } if items and has_next else None, + "has_next": has_next, + } +``` + +--- + +## 7. 집계 함수 (Aggregation) + +### 7.1 기본 집계 함수 + +```python +from sqlalchemy import select, func + + +# ───────────────────────────────────────────────────── +# COUNT +# ───────────────────────────────────────────────────── + +# 전체 개수 +stmt = select(func.count(User.id)) +total = db.scalar(stmt) + +# 조건부 개수 +stmt = select(func.count(User.id)).where(User.is_active == True) +active_count = db.scalar(stmt) + +# COUNT(DISTINCT) +stmt = select(func.count(func.distinct(User.category_id))) +unique_categories = db.scalar(stmt) + + +# ───────────────────────────────────────────────────── +# SUM +# ───────────────────────────────────────────────────── + +stmt = select(func.sum(Order.amount)) +total_amount = db.scalar(stmt) + +stmt = select(func.sum(Order.amount)).where(Order.status == "completed") +completed_amount = db.scalar(stmt) + + +# ───────────────────────────────────────────────────── +# AVG +# ───────────────────────────────────────────────────── + +stmt = select(func.avg(Product.price)) +avg_price = db.scalar(stmt) + +# 반올림 +stmt = select(func.round(func.avg(Product.price), 2)) +avg_price_rounded = db.scalar(stmt) + + +# ───────────────────────────────────────────────────── +# MIN / MAX +# ───────────────────────────────────────────────────── + +stmt = select(func.min(Product.price), func.max(Product.price)) +result = db.execute(stmt).first() +min_price, max_price = result + + +# ───────────────────────────────────────────────────── +# 여러 집계 함수 함께 사용 +# ───────────────────────────────────────────────────── + +stmt = select( + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_amount"), + func.avg(Order.amount).label("avg_amount"), + func.min(Order.amount).label("min_amount"), + func.max(Order.amount).label("max_amount"), +) +result = db.execute(stmt).first() + +print(f"주문 수: {result.order_count}") +print(f"총 금액: {result.total_amount}") +print(f"평균 금액: {result.avg_amount}") +``` + +### 7.2 GROUP BY + +```python +# ───────────────────────────────────────────────────── +# 기본 GROUP BY +# ───────────────────────────────────────────────────── + +# 카테고리별 상품 수 +stmt = ( + select( + Product.category_id, + func.count(Product.id).label("product_count"), + ) + .group_by(Product.category_id) +) +results = db.execute(stmt).all() + +for row in results: + print(f"Category {row.category_id}: {row.product_count} products") + + +# ───────────────────────────────────────────────────── +# JOIN과 함께 GROUP BY +# ───────────────────────────────────────────────────── + +# 카테고리 이름과 함께 +stmt = ( + select( + Category.name, + func.count(Product.id).label("product_count"), + func.avg(Product.price).label("avg_price"), + ) + .join(Product, Category.id == Product.category_id) + .group_by(Category.id, Category.name) +) + + +# ───────────────────────────────────────────────────── +# 날짜별 GROUP BY +# ───────────────────────────────────────────────────── + +# 일별 주문 통계 +stmt = ( + select( + func.date(Order.created_at).label("order_date"), + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_amount"), + ) + .group_by(func.date(Order.created_at)) + .order_by(func.date(Order.created_at).desc()) +) + +# 월별 주문 통계 +stmt = ( + select( + func.year(Order.created_at).label("year"), + func.month(Order.created_at).label("month"), + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_amount"), + ) + .group_by( + func.year(Order.created_at), + func.month(Order.created_at), + ) + .order_by( + func.year(Order.created_at).desc(), + func.month(Order.created_at).desc(), + ) +) + + +# ───────────────────────────────────────────────────── +# 다중 컬럼 GROUP BY +# ───────────────────────────────────────────────────── + +stmt = ( + select( + User.country, + User.city, + func.count(User.id).label("user_count"), + ) + .group_by(User.country, User.city) + .order_by(func.count(User.id).desc()) +) +``` + +### 7.3 HAVING + +```python +# ───────────────────────────────────────────────────── +# 집계 결과 필터링 +# ───────────────────────────────────────────────────── + +# 5개 이상 상품이 있는 카테고리만 +stmt = ( + select( + Category.name, + func.count(Product.id).label("product_count"), + ) + .join(Product) + .group_by(Category.id, Category.name) + .having(func.count(Product.id) >= 5) +) + + +# ───────────────────────────────────────────────────── +# 복합 HAVING 조건 +# ───────────────────────────────────────────────────── + +stmt = ( + select( + User.id, + User.username, + func.count(Order.id).label("order_count"), + func.sum(Order.amount).label("total_spent"), + ) + .join(Order) + .group_by(User.id, User.username) + .having( + and_( + func.count(Order.id) >= 10, + func.sum(Order.amount) >= 100000, + ) + ) + .order_by(func.sum(Order.amount).desc()) +) +# 10건 이상 주문하고 10만원 이상 사용한 고객 +``` + +### 7.4 윈도우 함수 + +```python +from sqlalchemy import over + + +# ───────────────────────────────────────────────────── +# ROW_NUMBER +# ───────────────────────────────────────────────────── + +stmt = ( + select( + User.id, + User.username, + User.created_at, + func.row_number().over( + order_by=User.created_at.desc() + ).label("row_num"), + ) +) + + +# ───────────────────────────────────────────────────── +# 파티션별 순위 +# ───────────────────────────────────────────────────── + +stmt = ( + select( + Product.id, + Product.name, + Product.category_id, + Product.price, + func.rank().over( + partition_by=Product.category_id, + order_by=Product.price.desc(), + ).label("price_rank"), + ) +) +# 카테고리 내에서 가격 순위 + + +# ───────────────────────────────────────────────────── +# 누적 합계 +# ───────────────────────────────────────────────────── + +stmt = ( + select( + Order.id, + Order.created_at, + Order.amount, + func.sum(Order.amount).over( + order_by=Order.created_at, + ).label("cumulative_amount"), + ) +) + + +# ───────────────────────────────────────────────────── +# 이동 평균 +# ───────────────────────────────────────────────────── + +from sqlalchemy import text + +stmt = ( + select( + Order.id, + Order.created_at, + Order.amount, + func.avg(Order.amount).over( + order_by=Order.created_at, + rows=(2, 0), # 현재 행 포함 최근 3개 + ).label("moving_avg"), + ) +) +``` + +--- + +## 8. JOIN 쿼리 + +### 8.1 기본 JOIN + +```python +from sqlalchemy import select + + +# ───────────────────────────────────────────────────── +# INNER JOIN +# ───────────────────────────────────────────────────── + +# 방법 1: relationship 사용 (자동 조건) +stmt = select(User, Post).join(User.posts) +# SELECT ... FROM user JOIN post ON user.id = post.author_id + +# 방법 2: 명시적 조건 +stmt = select(User, Post).join(Post, User.id == Post.author_id) + +# 방법 3: 모델만 지정 (FK 자동 감지) +stmt = select(User, Post).join(Post) + + +# ───────────────────────────────────────────────────── +# LEFT OUTER JOIN +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post, isouter=True) +# SELECT ... FROM user LEFT JOIN post ON ... + +# 또는 +stmt = select(User, Post).outerjoin(Post) + + +# ───────────────────────────────────────────────────── +# RIGHT OUTER JOIN +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post, full=True) # FULL OUTER +# RIGHT JOIN은 순서를 바꿔서 구현 +stmt = select(Post, User).outerjoin(User) + + +# ───────────────────────────────────────────────────── +# 다중 JOIN +# ───────────────────────────────────────────────────── + +stmt = ( + select(User, Post, Comment) + .join(Post, User.id == Post.author_id) + .join(Comment, Post.id == Comment.post_id) +) + +# relationship 사용 +stmt = ( + select(User, Post, Comment) + .join(User.posts) + .join(Post.comments) +) +``` + +### 8.2 JOIN 결과 처리 + +```python +# ───────────────────────────────────────────────────── +# 여러 모델 조회 +# ───────────────────────────────────────────────────── + +stmt = select(User, Post).join(Post) +results = db.execute(stmt).all() + +for user, post in results: + print(f"Author: {user.username}, Post: {post.title}") + + +# ───────────────────────────────────────────────────── +# 특정 컬럼만 조회 +# ───────────────────────────────────────────────────── + +stmt = ( + select(User.username, Post.title, Post.created_at) + .join(Post) + .order_by(Post.created_at.desc()) +) +results = db.execute(stmt).all() + +for row in results: + print(f"{row.username}: {row.title}") + + +# ───────────────────────────────────────────────────── +# 하나의 모델만 필요할 때 +# ───────────────────────────────────────────────────── + +# 게시글이 있는 사용자만 조회 +stmt = ( + select(User) + .join(Post) + .distinct() +) +users = db.scalars(stmt).all() +``` + +### 8.3 Self JOIN + +```python +# ───────────────────────────────────────────────────── +# 같은 테이블 조인 (별칭 필요) +# ───────────────────────────────────────────────────── + +from sqlalchemy.orm import aliased + +# 사용자와 그 추천인 +Referrer = aliased(User, name="referrer") + +stmt = ( + select(User.username, Referrer.username.label("referrer_name")) + .join(Referrer, User.referrer_id == Referrer.id, isouter=True) +) + + +# ───────────────────────────────────────────────────── +# 계층 구조 조회 (상위/하위) +# ───────────────────────────────────────────────────── + +Parent = aliased(Category, name="parent") + +stmt = ( + select(Category.name, Parent.name.label("parent_name")) + .join(Parent, Category.parent_id == Parent.id, isouter=True) +) + + +# ───────────────────────────────────────────────────── +# 같은 테이블 비교 +# ───────────────────────────────────────────────────── + +# 같은 카테고리의 다른 상품 +OtherProduct = aliased(Product, name="other") + +stmt = ( + select(Product.name, OtherProduct.name.label("related_product")) + .join( + OtherProduct, + and_( + Product.category_id == OtherProduct.category_id, + Product.id != OtherProduct.id, + ) + ) + .where(Product.id == 1) +) +``` + +--- + +## 9. 서브쿼리 (Subquery) + +### 9.1 스칼라 서브쿼리 + +```python +from sqlalchemy import select, func + + +# ───────────────────────────────────────────────────── +# SELECT 절의 서브쿼리 +# ───────────────────────────────────────────────────── + +# 각 사용자의 게시글 수 +post_count_subq = ( + select(func.count(Post.id)) + .where(Post.author_id == User.id) + .correlate(User) + .scalar_subquery() +) + +stmt = select( + User.username, + post_count_subq.label("post_count"), +) + + +# ───────────────────────────────────────────────────── +# 평균과 비교 +# ───────────────────────────────────────────────────── + +avg_price = select(func.avg(Product.price)).scalar_subquery() + +stmt = select(Product).where(Product.price > avg_price) +# 평균보다 비싼 상품 +``` + +### 9.2 FROM 절의 서브쿼리 + +```python +# ───────────────────────────────────────────────────── +# 서브쿼리를 테이블처럼 사용 +# ───────────────────────────────────────────────────── + +# 카테고리별 통계 서브쿼리 +category_stats = ( + select( + Product.category_id, + func.count(Product.id).label("product_count"), + func.avg(Product.price).label("avg_price"), + ) + .group_by(Product.category_id) + .subquery() +) + +# 메인 쿼리에서 조인 +stmt = ( + select( + Category.name, + category_stats.c.product_count, + category_stats.c.avg_price, + ) + .join(category_stats, Category.id == category_stats.c.category_id) +) + + +# ───────────────────────────────────────────────────── +# 파생 테이블 +# ───────────────────────────────────────────────────── + +# 최근 7일 활성 사용자 +recent_active = ( + select( + User.id, + User.username, + func.count(Order.id).label("order_count"), + ) + .join(Order) + .where(Order.created_at >= func.date_sub(func.now(), text("INTERVAL 7 DAY"))) + .group_by(User.id) + .subquery() +) + +# 많이 주문한 순으로 +stmt = ( + select(recent_active) + .order_by(recent_active.c.order_count.desc()) + .limit(10) +) +``` + +### 9.3 WHERE 절의 서브쿼리 + +```python +from sqlalchemy import exists, any_, all_ + + +# ───────────────────────────────────────────────────── +# IN 서브쿼리 +# ───────────────────────────────────────────────────── + +# 주문한 적 있는 사용자 +ordered_user_ids = select(Order.user_id).distinct() + +stmt = select(User).where(User.id.in_(ordered_user_ids)) + + +# ───────────────────────────────────────────────────── +# EXISTS +# ───────────────────────────────────────────────────── + +# 게시글이 있는 사용자 +has_posts = ( + exists() + .where(Post.author_id == User.id) +) + +stmt = select(User).where(has_posts) + + +# NOT EXISTS +stmt = select(User).where(~has_posts) # 게시글 없는 사용자 + + +# ───────────────────────────────────────────────────── +# ANY / ALL +# ───────────────────────────────────────────────────── + +# 어떤 주문보다 비싼 상품 (ANY) +any_order_amount = select(Order.amount) +stmt = select(Product).where(Product.price > any_(any_order_amount)) + +# 모든 주문보다 비싼 상품 (ALL) +stmt = select(Product).where(Product.price > all_(any_order_amount)) +``` + +### 9.4 Lateral 서브쿼리 (PostgreSQL) + +```python +from sqlalchemy import lateral + + +# 각 사용자의 최근 3개 주문 +recent_orders = ( + select(Order) + .where(Order.user_id == User.id) + .order_by(Order.created_at.desc()) + .limit(3) + .lateral() +) + +stmt = ( + select(User, recent_orders) + .outerjoin(recent_orders, True) +) +``` + +--- + +## 10. 집합 연산 (Union, Intersect, Except) + +### 10.1 UNION + +```python +from sqlalchemy import union, union_all + + +# ───────────────────────────────────────────────────── +# UNION (중복 제거) +# ───────────────────────────────────────────────────── + +# 관리자 + 최근 활동 사용자 +admins = select(User.id, User.username).where(User.is_admin == True) +recent_active = select(User.id, User.username).where( + User.last_login >= func.date_sub(func.now(), text("INTERVAL 7 DAY")) +) + +stmt = union(admins, recent_active) +results = db.execute(stmt).all() + + +# ───────────────────────────────────────────────────── +# UNION ALL (중복 허용, 더 빠름) +# ───────────────────────────────────────────────────── + +stmt = union_all(admins, recent_active) + + +# ───────────────────────────────────────────────────── +# 여러 개 UNION +# ───────────────────────────────────────────────────── + +query1 = select(User.email.label("contact")).where(User.is_active == True) +query2 = select(Contact.email.label("contact")).where(Contact.subscribed == True) +query3 = select(Lead.email.label("contact")).where(Lead.status == "qualified") + +stmt = union(query1, query2, query3) + + +# ───────────────────────────────────────────────────── +# UNION 결과 정렬/제한 +# ───────────────────────────────────────────────────── + +combined = union(admins, recent_active).subquery() + +stmt = ( + select(combined) + .order_by(combined.c.username) + .limit(10) +) +``` + +### 10.2 INTERSECT + +```python +from sqlalchemy import intersect, intersect_all + + +# ───────────────────────────────────────────────────── +# INTERSECT (교집합) +# ───────────────────────────────────────────────────── + +# 관리자이면서 최근 활동한 사용자 +admins = select(User.id).where(User.is_admin == True) +recent_active = select(User.id).where( + User.last_login >= func.date_sub(func.now(), text("INTERVAL 7 DAY")) +) + +stmt = intersect(admins, recent_active) + + +# ───────────────────────────────────────────────────── +# 여러 조건의 교집합 +# ───────────────────────────────────────────────────── + +# 세 가지 모두 만족하는 사용자 +has_orders = select(Order.user_id).distinct() +has_reviews = select(Review.user_id).distinct() +is_verified = select(User.id).where(User.is_verified == True) + +stmt = intersect(has_orders, has_reviews, is_verified) +``` + +### 10.3 EXCEPT + +```python +from sqlalchemy import except_, except_all + + +# ───────────────────────────────────────────────────── +# EXCEPT (차집합) +# ───────────────────────────────────────────────────── + +# 주문은 했지만 리뷰는 안 쓴 사용자 +ordered = select(Order.user_id).distinct() +reviewed = select(Review.user_id).distinct() + +stmt = except_(ordered, reviewed) + + +# ───────────────────────────────────────────────────── +# 실용 예: 미처리 항목 찾기 +# ───────────────────────────────────────────────────── + +# 모든 신규 사용자 중 환영 이메일 미발송 대상 +all_new_users = select(User.id).where( + User.created_at >= func.date_sub(func.now(), text("INTERVAL 24 HOUR")) +) +email_sent = select(EmailLog.user_id).where(EmailLog.type == "welcome") + +stmt = except_(all_new_users, email_sent) +``` + +--- + +## 11. 고급 표현식 + +### 11.1 CASE 문 + +```python +from sqlalchemy import case + + +# ───────────────────────────────────────────────────── +# 단순 CASE +# ───────────────────────────────────────────────────── + +status_label = case( + (User.status == "active", "활성"), + (User.status == "pending", "대기"), + (User.status == "suspended", "정지"), + else_="알 수 없음", +) + +stmt = select(User.username, status_label.label("status_label")) + + +# ───────────────────────────────────────────────────── +# 조건부 집계 +# ───────────────────────────────────────────────────── + +stmt = select( + func.count(case((Order.status == "completed", 1))).label("completed_count"), + func.count(case((Order.status == "pending", 1))).label("pending_count"), + func.count(case((Order.status == "cancelled", 1))).label("cancelled_count"), + func.sum(case((Order.status == "completed", Order.amount), else_=0)).label("completed_amount"), +) + + +# ───────────────────────────────────────────────────── +# 정렬에서 CASE +# ───────────────────────────────────────────────────── + +priority_order = case( + (User.role == "admin", 1), + (User.role == "moderator", 2), + else_=3, +) + +stmt = select(User).order_by(priority_order, User.username) +``` + +### 11.2 형변환 (CAST) + +```python +from sqlalchemy import cast +from sqlalchemy.types import String, Integer, Float, Date + + +# ───────────────────────────────────────────────────── +# 타입 변환 +# ───────────────────────────────────────────────────── + +stmt = select(cast(User.age, String).label("age_str")) +stmt = select(cast(Product.price, Integer).label("price_int")) +stmt = select(cast(Order.created_at, Date).label("order_date")) + + +# ───────────────────────────────────────────────────── +# 문자열 연결 시 형변환 +# ───────────────────────────────────────────────────── + +stmt = select( + (User.username + " (" + cast(User.age, String) + "세)").label("display_name") +) +``` + +### 11.3 문자열 함수 + +```python +from sqlalchemy import func + + +# ───────────────────────────────────────────────────── +# 기본 문자열 함수 +# ───────────────────────────────────────────────────── + +stmt = select( + func.upper(User.username), + func.lower(User.email), + func.length(User.bio), + func.trim(User.name), + func.concat(User.first_name, " ", User.last_name).label("full_name"), +) + + +# ───────────────────────────────────────────────────── +# 문자열 추출 +# ───────────────────────────────────────────────────── + +stmt = select( + func.substring(User.email, 1, 10), # 처음 10자 + func.left(User.username, 5), + func.right(User.email, 10), +) + + +# ───────────────────────────────────────────────────── +# 문자열 연결 (|| 연산자) +# ───────────────────────────────────────────────────── + +from sqlalchemy import literal_column + +# PostgreSQL/SQLite +full_name = User.first_name + " " + User.last_name + +# MySQL +full_name = func.concat(User.first_name, " ", User.last_name) +``` + +### 11.4 날짜/시간 함수 + +```python +from sqlalchemy import func, extract + + +# ───────────────────────────────────────────────────── +# 현재 날짜/시간 +# ───────────────────────────────────────────────────── + +stmt = select( + func.now(), + func.current_date(), + func.current_time(), + func.current_timestamp(), +) + + +# ───────────────────────────────────────────────────── +# 날짜 추출 +# ───────────────────────────────────────────────────── + +stmt = select( + extract("year", Order.created_at).label("year"), + extract("month", Order.created_at).label("month"), + extract("day", Order.created_at).label("day"), + extract("hour", Order.created_at).label("hour"), +) + +# 또는 +stmt = select( + func.year(Order.created_at), + func.month(Order.created_at), + func.day(Order.created_at), +) + + +# ───────────────────────────────────────────────────── +# 날짜 연산 +# ───────────────────────────────────────────────────── + +# MySQL +from sqlalchemy import text + +stmt = select(User).where( + User.created_at >= func.date_sub(func.now(), text("INTERVAL 30 DAY")) +) + +# PostgreSQL +from datetime import timedelta +stmt = select(User).where( + User.created_at >= func.now() - timedelta(days=30) +) + + +# ───────────────────────────────────────────────────── +# 날짜 차이 +# ───────────────────────────────────────────────────── + +stmt = select( + func.datediff(func.now(), User.created_at).label("days_since_signup") +) +``` + +### 11.5 NULL 처리 + +```python +from sqlalchemy import func, coalesce, nullif + + +# ───────────────────────────────────────────────────── +# COALESCE (첫 번째 non-null 값) +# ───────────────────────────────────────────────────── + +stmt = select( + coalesce(User.nickname, User.username, "Anonymous").label("display_name") +) + + +# ───────────────────────────────────────────────────── +# NULLIF (같으면 NULL) +# ───────────────────────────────────────────────────── + +# 0으로 나누기 방지 +stmt = select( + Order.total / nullif(Order.quantity, 0) +) + + +# ───────────────────────────────────────────────────── +# IFNULL / NVL (MySQL / Oracle) +# ───────────────────────────────────────────────────── + +stmt = select( + func.ifnull(User.nickname, "No nickname") +) +``` + +### 11.6 Raw SQL 사용 + +```python +from sqlalchemy import text, literal_column + + +# ───────────────────────────────────────────────────── +# text() - Raw SQL +# ───────────────────────────────────────────────────── + +# WHERE 절에서 +stmt = select(User).where(text("MATCH(bio) AGAINST(:keyword)")).params(keyword="python") + +# 전체 쿼리 +result = db.execute(text("SELECT * FROM user WHERE id = :id"), {"id": 1}) + + +# ───────────────────────────────────────────────────── +# literal_column() - 컬럼 표현식 +# ───────────────────────────────────────────────────── + +stmt = select( + User.username, + literal_column("'active'").label("status"), +) + + +# ───────────────────────────────────────────────────── +# literal() - 리터럴 값 +# ───────────────────────────────────────────────────── + +from sqlalchemy import literal + +stmt = select( + User.username, + literal(1).label("constant"), + literal("active").label("status"), +) +``` + +--- + +## 12. Relationship과 Eager Loading + +### 12.1 Lazy Loading (기본) + +```python +# ───────────────────────────────────────────────────── +# N+1 문제 발생 예시 +# ───────────────────────────────────────────────────── + +users = db.scalars(select(User)).all() # 쿼리 1회 + +for user in users: + print(user.posts) # 각 사용자마다 추가 쿼리! (N회) + +# 총 N+1회 쿼리 발생 +``` + +### 12.2 Eager Loading 옵션 + +```python +from sqlalchemy.orm import selectinload, joinedload, subqueryload, raiseload + + +# ───────────────────────────────────────────────────── +# selectinload (권장: 1:N) +# ───────────────────────────────────────────────────── + +stmt = select(User).options(selectinload(User.posts)) +users = db.scalars(stmt).all() + +# 쿼리 1: SELECT * FROM user +# 쿼리 2: SELECT * FROM post WHERE user_id IN (1, 2, 3, ...) + +for user in users: + print(user.posts) # 추가 쿼리 없음! + + +# ───────────────────────────────────────────────────── +# joinedload (권장: N:1, 1:1) +# ───────────────────────────────────────────────────── + +stmt = select(Post).options(joinedload(Post.author)) +posts = db.scalars(stmt).unique().all() # unique() 필요! + +# 쿼리: SELECT post.*, user.* FROM post JOIN user ON ... + + +# ───────────────────────────────────────────────────── +# subqueryload +# ───────────────────────────────────────────────────── + +stmt = select(User).options(subqueryload(User.posts)) + +# 쿼리 1: SELECT * FROM user +# 쿼리 2: SELECT * FROM post WHERE user_id IN (SELECT id FROM user) + + +# ───────────────────────────────────────────────────── +# raiseload (로딩 금지) +# ───────────────────────────────────────────────────── + +stmt = select(User).options(raiseload(User.posts)) +user = db.scalar(stmt) +print(user.posts) # 에러 발생! 명시적 로딩 필요 +``` + +### 12.3 중첩 Eager Loading + +```python +# ───────────────────────────────────────────────────── +# 다단계 관계 로딩 +# ───────────────────────────────────────────────────── + +stmt = ( + select(User) + .options( + selectinload(User.posts) + .selectinload(Post.comments) + .selectinload(Comment.author) + ) +) + +# 쿼리 1: SELECT * FROM user +# 쿼리 2: SELECT * FROM post WHERE user_id IN (...) +# 쿼리 3: SELECT * FROM comment WHERE post_id IN (...) +# 쿼리 4: SELECT * FROM user WHERE id IN (...) # 댓글 작성자 + + +# ───────────────────────────────────────────────────── +# 여러 관계 동시 로딩 +# ───────────────────────────────────────────────────── + +stmt = ( + select(User) + .options( + selectinload(User.posts), + selectinload(User.comments), + joinedload(User.profile), + ) +) + + +# ───────────────────────────────────────────────────── +# contains_eager (이미 조인된 경우) +# ───────────────────────────────────────────────────── + +from sqlalchemy.orm import contains_eager + +stmt = ( + select(Post) + .join(Post.author) + .where(User.is_active == True) + .options(contains_eager(Post.author)) # 조인 결과 사용 +) +``` + +### 12.4 로딩 전략 비교 + +| 전략 | 쿼리 수 | 적합한 관계 | 장점 | 단점 | +|------|--------|------------|------|------| +| `selectinload` | 2 | 1:N | 효율적, 간단 | 대량 ID 시 IN 절 길어짐 | +| `joinedload` | 1 | N:1, 1:1 | 단일 쿼리 | 1:N에서 중복 발생 | +| `subqueryload` | 2 | 1:N | 복잡한 필터 지원 | 서브쿼리 오버헤드 | +| `raiseload` | - | - | 실수 방지 | 명시적 로딩 필요 | + +--- + +## 13. 트랜잭션 관리 + +### 13.1 기본 트랜잭션 + +```python +from sqlalchemy.orm import Session + + +# ───────────────────────────────────────────────────── +# 명시적 commit/rollback +# ───────────────────────────────────────────────────── + +def create_order(db: Session, user_id: int, items: list) -> Order: + try: + order = Order(user_id=user_id) + db.add(order) + db.flush() # ID 생성을 위해 flush + + for item in items: + order_item = OrderItem( + order_id=order.id, + product_id=item["product_id"], + quantity=item["quantity"], + ) + db.add(order_item) + + db.commit() + db.refresh(order) + return order + + except Exception as e: + db.rollback() + raise + + +# ───────────────────────────────────────────────────── +# context manager 사용 +# ───────────────────────────────────────────────────── + +from sqlalchemy.orm import Session + +def transfer_money(engine, from_id: int, to_id: int, amount: float): + with Session(engine) as session: + with session.begin(): # 자동 commit/rollback + from_account = session.get(Account, from_id) + to_account = session.get(Account, to_id) + + if from_account.balance < amount: + raise ValueError("잔액 부족") + + from_account.balance -= amount + to_account.balance += amount + # begin() 블록 종료 시 자동 commit + # Session 종료 시 자동 close +``` + +### 13.2 Savepoint (중첩 트랜잭션) + +```python +def complex_operation(db: Session): + try: + # 메인 작업 + user = User(username="test") + db.add(user) + db.flush() + + # 선택적 작업 (실패해도 메인은 유지) + savepoint = db.begin_nested() + try: + risky_operation() + savepoint.commit() + except Exception: + savepoint.rollback() + # 메인 트랜잭션은 유지됨 + + db.commit() + + except Exception: + db.rollback() + raise +``` + +### 13.3 FastAPI에서 트랜잭션 + +```python +# app/database.py +from contextlib import contextmanager + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + except Exception: + db.rollback() + raise + finally: + db.close() + + +# 트랜잭션이 필요한 서비스 +class OrderService: + def __init__(self, db: Session): + self.db = db + + def create_order_with_payment(self, data: OrderCreate) -> Order: + # 여러 작업이 하나의 트랜잭션 + order = self._create_order(data) + self._process_payment(order) + self._update_inventory(order) + self._send_notification(order) + + self.db.commit() + return order + + +# 라우터 +@router.post("/orders") +def create_order( + data: OrderCreate, + db: Session = Depends(get_db), +): + service = OrderService(db) + return service.create_order_with_payment(data) +``` + +--- + +## 14. FastAPI 통합 패턴 + +### 14.1 Repository 패턴 + +```python +# app/repositories/base.py +from typing import TypeVar, Generic, Type, Optional, List +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.base import Base + +ModelType = TypeVar("ModelType", bound=Base) + + +class BaseRepository(Generic[ModelType]): + def __init__(self, model: Type[ModelType], db: Session): + self.model = model + self.db = db + + def get(self, id: int) -> Optional[ModelType]: + return self.db.get(self.model, id) + + def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]: + stmt = select(self.model).offset(skip).limit(limit) + return list(self.db.scalars(stmt).all()) + + def create(self, obj: ModelType) -> ModelType: + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def update(self, obj: ModelType) -> ModelType: + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, obj: ModelType) -> None: + self.db.delete(obj) + self.db.commit() + + +# app/repositories/user.py +from sqlalchemy import select +from app.models.user import User + + +class UserRepository(BaseRepository[User]): + def __init__(self, db: Session): + super().__init__(User, db) + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.scalar(stmt) + + def get_active_users(self) -> List[User]: + stmt = select(User).where(User.is_active == True) + return list(self.db.scalars(stmt).all()) + + def search(self, keyword: str) -> List[User]: + stmt = select(User).where( + or_( + User.username.contains(keyword), + User.email.contains(keyword), + ) + ) + return list(self.db.scalars(stmt).all()) +``` + +### 14.2 Service 패턴 + +```python +# app/services/user.py +from typing import Optional, List +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate +from app.repositories.user import UserRepository +from app.core.security import hash_password + + +class UserService: + def __init__(self, db: Session): + self.db = db + self.repository = UserRepository(db) + + def get_user(self, user_id: int) -> User: + user = self.repository.get(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + def get_user_by_email(self, email: str) -> Optional[User]: + return self.repository.get_by_email(email) + + def create_user(self, data: UserCreate) -> User: + # 이메일 중복 확인 + if self.repository.get_by_email(data.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + user = User( + email=data.email, + username=data.username, + hashed_password=hash_password(data.password), + ) + return self.repository.create(user) + + def update_user(self, user_id: int, data: UserUpdate) -> User: + user = self.get_user(user_id) + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(user, field, value) + + return self.repository.update(user) + + def delete_user(self, user_id: int) -> None: + user = self.get_user(user_id) + self.repository.delete(user) + + +# Dependency +def get_user_service(db: Session = Depends(get_db)) -> UserService: + return UserService(db) +``` + +### 14.3 Router (Endpoints) + +```python +# app/routers/user.py +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from app.database import get_db +from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserListResponse +from app.services.user import UserService, get_user_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/", response_model=UserListResponse) +def list_users( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + service: UserService = Depends(get_user_service), +): + users = service.repository.get_all(skip=skip, limit=limit) + return {"items": users, "total": len(users)} + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user( + user_id: int, + service: UserService = Depends(get_user_service), +): + return service.get_user(user_id) + + +@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def create_user( + data: UserCreate, + service: UserService = Depends(get_user_service), +): + return service.create_user(data) + + +@router.patch("/{user_id}", response_model=UserResponse) +def update_user( + user_id: int, + data: UserUpdate, + service: UserService = Depends(get_user_service), +): + return service.update_user(user_id, data) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + service: UserService = Depends(get_user_service), +): + service.delete_user(user_id) +``` + +### 14.4 Pydantic 스키마 + +```python +# app/schemas/user.py +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +class UserBase(BaseModel): + email: EmailStr + username: str = Field(..., min_length=3, max_length=50) + full_name: Optional[str] = None + + +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = Field(None, min_length=3, max_length=50) + full_name: Optional[str] = None + is_active: Optional[bool] = None + + +class UserResponse(UserBase): + id: int + is_active: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class UserListResponse(BaseModel): + items: List[UserResponse] + total: int +``` + +--- + +## 15. 성능 최적화 + +### 15.1 인덱스 최적화 + +```python +from sqlalchemy import Index + + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True) + status: Mapped[str] = mapped_column(String(20)) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + __table_args__ = ( + # 단일 인덱스 + Index("idx_user_status", "status"), + + # 복합 인덱스 + Index("idx_user_status_created", "status", "created_at"), + + # 부분 인덱스 (PostgreSQL) + Index( + "idx_user_active", + "email", + postgresql_where=text("status = 'active'"), + ), + ) +``` + +### 15.2 쿼리 최적화 + +```python +# ───────────────────────────────────────────────────── +# 1. 필요한 컬럼만 조회 +# ───────────────────────────────────────────────────── + +# Bad +users = db.scalars(select(User)).all() +emails = [u.email for u in users] + +# Good +emails = db.scalars(select(User.email)).all() + + +# ───────────────────────────────────────────────────── +# 2. N+1 문제 해결 +# ───────────────────────────────────────────────────── + +# Bad +users = db.scalars(select(User)).all() +for user in users: + print(user.posts) # N+1 + +# Good +stmt = select(User).options(selectinload(User.posts)) +users = db.scalars(stmt).all() + + +# ───────────────────────────────────────────────────── +# 3. Bulk 작업 사용 +# ───────────────────────────────────────────────────── + +# Bad - 개별 업데이트 +for user in users: + user.is_active = False + db.commit() + +# Good - Bulk 업데이트 +db.execute( + update(User) + .where(User.id.in_([u.id for u in users])) + .values(is_active=False) +) +db.commit() + + +# ───────────────────────────────────────────────────── +# 4. 존재 확인은 EXISTS 사용 +# ───────────────────────────────────────────────────── + +# Bad +user = db.scalar(select(User).where(User.email == email)) +exists = user is not None + +# Good +exists = db.scalar( + select(exists().where(User.email == email)) +) + + +# ───────────────────────────────────────────────────── +# 5. 카운트는 count() 사용 +# ───────────────────────────────────────────────────── + +# Bad +count = len(db.scalars(select(User)).all()) + +# Good +count = db.scalar(select(func.count(User.id))) +``` + +### 15.3 연결 풀 설정 + +```python +engine = create_engine( + DATABASE_URL, + pool_size=10, # 기본 연결 수 + max_overflow=20, # 추가 허용 연결 수 + pool_timeout=30, # 연결 대기 시간 + pool_recycle=1800, # 연결 재생성 주기 (30분) + pool_pre_ping=True, # 연결 유효성 사전 검사 +) +``` + +### 15.4 쿼리 실행 계획 확인 + +```python +from sqlalchemy import explain + +stmt = select(User).where(User.email == "test@example.com") + +# 실행 계획 출력 +print(db.execute(explain(stmt)).fetchall()) + +# MySQL EXPLAIN +print(db.execute(text(f"EXPLAIN {stmt}")).fetchall()) +``` + +--- + +## 16. 실무 레시피 + +### 16.1 Soft Delete 구현 + +```python +from datetime import datetime +from sqlalchemy import event + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(default=False) + deleted_at: Mapped[Optional[datetime]] = mapped_column(default=None) + + def soft_delete(self): + self.is_deleted = True + self.deleted_at = datetime.utcnow() + + +# 자동 필터링 (Global Filter) +@event.listens_for(Session, "do_orm_execute") +def _add_filtering_criteria(execute_state): + if execute_state.is_select: + execute_state.statement = execute_state.statement.options( + with_loader_criteria( + SoftDeleteMixin, + lambda cls: cls.is_deleted == False, + include_aliases=True, + ) + ) +``` + +### 16.2 Audit Log + +```python +from sqlalchemy import event + + +class AuditMixin: + created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id")) + updated_by: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id")) + + +# 현재 사용자 컨텍스트 +from contextvars import ContextVar +current_user_id: ContextVar[Optional[int]] = ContextVar("current_user_id", default=None) + + +@event.listens_for(AuditMixin, "before_insert", propagate=True) +def set_created_by(mapper, connection, target): + if user_id := current_user_id.get(): + target.created_by = user_id + target.updated_by = user_id + + +@event.listens_for(AuditMixin, "before_update", propagate=True) +def set_updated_by(mapper, connection, target): + if user_id := current_user_id.get(): + target.updated_by = user_id + + +# FastAPI 미들웨어 +@app.middleware("http") +async def set_current_user(request: Request, call_next): + user_id = get_user_id_from_token(request) + token = current_user_id.set(user_id) + try: + response = await call_next(request) + return response + finally: + current_user_id.reset(token) +``` + +### 16.3 전체 텍스트 검색 + +```python +from sqlalchemy import Index, text + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text) + + __table_args__ = ( + # MySQL FULLTEXT 인덱스 + Index( + "idx_post_fulltext", + "title", "content", + mysql_prefix="FULLTEXT", + ), + ) + + +def search_posts(db: Session, keyword: str) -> list[Post]: + stmt = ( + select(Post) + .where( + text("MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)") + ) + .params(keyword=keyword) + ) + return list(db.scalars(stmt).all()) +``` + +### 16.4 슬러그 자동 생성 + +```python +from sqlalchemy import event +import re + + +def slugify(text: str) -> str: + text = text.lower() + text = re.sub(r'[^\w\s-]', '', text) + text = re.sub(r'[-\s]+', '-', text).strip('-') + return text + + +class Post(Base): + title: Mapped[str] = mapped_column(String(200)) + slug: Mapped[str] = mapped_column(String(200), unique=True) + + +@event.listens_for(Post.title, "set") +def generate_slug(target, value, oldvalue, initiator): + if value and (not target.slug or oldvalue != value): + target.slug = slugify(value) +``` + +### 16.5 캐싱 패턴 + +```python +from functools import lru_cache +from typing import Optional +import json +import redis + +redis_client = redis.Redis() + + +class CachedUserRepository: + def __init__(self, db: Session): + self.db = db + self.cache_ttl = 3600 # 1시간 + + def _cache_key(self, user_id: int) -> str: + return f"user:{user_id}" + + def get(self, user_id: int) -> Optional[User]: + # 캐시 확인 + cached = redis_client.get(self._cache_key(user_id)) + if cached: + data = json.loads(cached) + return User(**data) + + # DB 조회 + user = self.db.get(User, user_id) + if user: + # 캐시 저장 + redis_client.setex( + self._cache_key(user_id), + self.cache_ttl, + json.dumps(user.to_dict()), + ) + + return user + + def invalidate(self, user_id: int) -> None: + redis_client.delete(self._cache_key(user_id)) +``` + +--- + +## 부록: Quick Reference + +### 자주 사용하는 import + +```python +from sqlalchemy import ( + create_engine, select, insert, update, delete, + and_, or_, not_, func, case, cast, exists, + text, literal, literal_column, + Index, ForeignKey, String, Integer, Text, Boolean, + desc, asc, nullsfirst, nullslast, +) +from sqlalchemy.orm import ( + Session, sessionmaker, relationship, + Mapped, mapped_column, + selectinload, joinedload, subqueryload, raiseload, + contains_eager, aliased, +) +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as pg_insert +``` + +### 쿼리 실행 메서드 + +```python +db.execute(stmt) # Result 반환 +db.scalars(stmt) # ScalarResult 반환 +db.scalar(stmt) # 단일 값 반환 + +result.all() # 모든 결과 +result.first() # 첫 번째 (없으면 None) +result.one() # 정확히 1개 (아니면 예외) +result.one_or_none() # 0-1개 (2개 이상 예외) +result.unique() # 중복 제거 +``` + +### 관계 로딩 전략 + +```python +selectinload(Model.relation) # 1:N - SELECT ... IN +joinedload(Model.relation) # N:1, 1:1 - JOIN +subqueryload(Model.relation) # 복잡한 필터 +raiseload(Model.relation) # 로딩 금지 +``` diff --git a/docs/reference/sqlalchemy_relationship_guide.md b/docs/reference/sqlalchemy_relationship_guide.md new file mode 100644 index 0000000..49e3f7b --- /dev/null +++ b/docs/reference/sqlalchemy_relationship_guide.md @@ -0,0 +1,1440 @@ +# SQLAlchemy Relationship 완벽 가이드 + +> SQLAlchemy 2.0+ / Python 3.10+ 기준 + +--- + +## 목차 + +1. [개요](#1-개요) +2. [기본 개념](#2-기본-개념) +3. [relationship 정의 문법](#3-relationship-정의-문법) +4. [부모와 자식에서의 정의 차이](#4-부모와-자식에서의-정의-차이) +5. [FK 필드 vs back_populates](#5-fk-필드-vs-back_populates) +6. [relationship 옵션 상세](#6-relationship-옵션-상세) +7. [관계별 정의 방법 (1:1, 1:N, N:M)](#7-관계별-정의-방법-11-1n-nm) +8. [ORM 사용법](#8-orm-사용법) +9. [실무 패턴](#9-실무-패턴) +10. [Quick Reference](#10-quick-reference) + +--- + +## 1. 개요 + +### 1.1 relationship이란? + +`relationship()`은 SQLAlchemy ORM에서 **테이블 간의 관계를 Python 객체로 매핑**하는 기능입니다. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Database Level ORM Level │ +│ ────────────── ───────── │ +│ FOREIGN KEY → relationship() │ +│ JOIN 쿼리 → object.related_objects │ +└─────────────────────────────────────────────────────────┘ +``` + +### 1.2 ForeignKey vs relationship + +| 구분 | ForeignKey | relationship | +|------|------------|--------------| +| **역할** | DB 레벨 제약조건 | ORM 레벨 객체 연결 | +| **위치** | 자식 테이블에만 | 양쪽 모두 가능 | +| **필수 여부** | FK 관계에 필수 | 선택사항 (편의 기능) | +| **결과** | 컬럼 생성 | Python 속성 생성 | + +```python +# ForeignKey: DB에 실제 컬럼 생성 +project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + +# relationship: Python 객체 접근 경로 생성 +project: Mapped["Project"] = relationship("Project") +``` + +--- + +## 2. 기본 개념 + +### 2.1 용어 정의 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 부모 (Parent) 자식 (Child) │ +│ ───────────── ───────────── │ +│ • "One" 쪽 • "Many" 쪽 │ +│ • FK를 참조받음 • FK를 정의함 │ +│ • 예: Project • 예: Image │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 관계 방향 + +```python +# 단방향 (Unidirectional) +# - 한쪽에서만 relationship 정의 +# - 반대쪽 접근 불가 + +# 양방향 (Bidirectional) +# - 양쪽 모두 relationship 정의 +# - back_populates로 연결 +# - 실무 권장 방식 +``` + +### 2.3 기본 예제 (가장 간단한 형태) + +```python +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +# 부모 테이블 +class Project(Base): + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + + # 자식들에 대한 접근 경로 + images: Mapped[list["Image"]] = relationship("Image", back_populates="project") + + +# 자식 테이블 +class Image(Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) # FK + filename: Mapped[str] = mapped_column(String(255)) + + # 부모에 대한 접근 경로 + project: Mapped["Project"] = relationship("Project", back_populates="images") +``` + +--- + +## 3. relationship 정의 문법 + +### 3.1 기본 문법 + +```python +from sqlalchemy.orm import relationship, Mapped +from typing import List + +# 기본 형태 +속성명: Mapped[타입] = relationship("대상클래스", 옵션들...) + +# 부모 측 (1:N에서 "1") +children: Mapped[List["Child"]] = relationship("Child", back_populates="parent") + +# 자식 측 (1:N에서 "N") +parent: Mapped["Parent"] = relationship("Parent", back_populates="children") +``` + +### 3.2 필수 항목 + +| 항목 | 설명 | 예시 | +|------|------|------| +| **첫 번째 인자** | 대상 모델 클래스명 (문자열) | `"Project"` | + +```python +# 최소한의 정의 (단방향) +project: Mapped["Project"] = relationship("Project") +``` + +### 3.3 권장 항목 + +| 항목 | 설명 | 예시 | +|------|------|------| +| `back_populates` | 반대편 relationship 속성명 | `back_populates="images"` | + +```python +# 권장하는 정의 (양방향) +project: Mapped["Project"] = relationship("Project", back_populates="images") +``` + +--- + +## 4. 부모와 자식에서의 정의 차이 + +### 4.1 구조 비교 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 부모 (Parent) │ +├─────────────────────────────────────────────────────────────────┤ +│ • ForeignKey: 없음 │ +│ • relationship 타입: List["Child"] 또는 list["Child"] │ +│ • cascade 옵션: 여기에 정의 (삭제 정책) │ +│ • 역할: 자식 컬렉션에 대한 접근 제공 │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ 참조 + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 자식 (Child) │ +├─────────────────────────────────────────────────────────────────┤ +│ • ForeignKey: 있음 (필수) │ +│ • relationship 타입: "Parent" (단수) │ +│ • cascade 옵션: 일반적으로 정의 안 함 │ +│ • 역할: 부모 객체에 대한 접근 제공 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 부모 클래스 정의 + +```python +class Project(Base): + """부모 클래스 - FK 없음, 자식 컬렉션 관리""" + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + store_name: Mapped[str] = mapped_column(String(255)) + + # ───────────────────────────────────────────────────── + # relationship 정의 (부모 측) + # ───────────────────────────────────────────────────── + images: Mapped[list["Image"]] = relationship( + "Image", # 대상 클래스 + back_populates="project", # 자식의 relationship 속성명 + cascade="all, delete-orphan", # 삭제 정책 (부모에서 정의) + lazy="selectin", # 로딩 전략 + order_by="Image.created_at", # 정렬 (선택) + ) +``` + +**부모 측 특징:** +- `Mapped[list["Child"]]` - 복수형 리스트 타입 +- `cascade` 옵션을 여기서 정의 +- FK 컬럼 없음 + +### 4.3 자식 클래스 정의 + +```python +class Image(Base): + """자식 클래스 - FK 있음, 부모 참조""" + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + original_filename: Mapped[str] = mapped_column(String(255)) + + # ───────────────────────────────────────────────────── + # ForeignKey 정의 (자식 측에서만) + # ───────────────────────────────────────────────────── + project_id: Mapped[int] = mapped_column( + ForeignKey("project.id", ondelete="CASCADE") + ) + + # ───────────────────────────────────────────────────── + # relationship 정의 (자식 측) + # ───────────────────────────────────────────────────── + project: Mapped["Project"] = relationship( + "Project", # 대상 클래스 + back_populates="images", # 부모의 relationship 속성명 + ) +``` + +**자식 측 특징:** +- `Mapped["Parent"]` - 단수형 타입 +- FK 컬럼 정의 필수 +- cascade는 보통 정의하지 않음 + +### 4.4 비교 표 + +| 항목 | 부모 (One) | 자식 (Many) | +|------|-----------|-------------| +| **ForeignKey** | ❌ 없음 | ✅ 필수 | +| **Mapped 타입** | `list["Child"]` | `"Parent"` | +| **cascade** | ✅ 여기서 정의 | ❌ 보통 안 함 | +| **back_populates** | 자식의 속성명 | 부모의 속성명 | +| **접근 결과** | 리스트 | 단일 객체 | + +--- + +## 5. FK 필드 vs back_populates + +### 5.1 두 가지 접근 방식 + +```python +class Image(Base): + # 방식 1: FK 필드 직접 사용 + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + + # 방식 2: relationship 사용 + project: Mapped["Project"] = relationship("Project", back_populates="images") +``` + +### 5.2 FK 필드 직접 사용 + +```python +# ───────────────────────────────────────────────────── +# FK 필드로 직접 조작 +# ───────────────────────────────────────────────────── + +# 생성 +image = Image( + project_id=1, # FK 값 직접 지정 + original_filename="photo.jpg" +) +session.add(image) +session.commit() + + +# 수정 +image = session.get(Image, 1) +image.project_id = 2 # FK 값 직접 변경 +session.commit() + + +# 조회 +image = session.get(Image, 1) +print(image.project_id) # 정수 값: 1 + +# 부모 객체 접근하려면 추가 쿼리 필요 +project = session.get(Project, image.project_id) +``` + +**특징:** +- 단순 정수 값 조작 +- 객체 관계를 신경 쓰지 않음 +- 추가 쿼리 없이 FK 값만 필요할 때 유용 + +### 5.3 relationship (back_populates) 사용 + +```python +# ───────────────────────────────────────────────────── +# relationship으로 객체 조작 +# ───────────────────────────────────────────────────── + +# 생성 - 객체로 연결 +project = session.get(Project, 1) +image = Image( + project=project, # 객체로 연결 (FK 자동 설정) + original_filename="photo.jpg" +) +session.add(image) +session.commit() + +print(image.project_id) # 자동으로 1 설정됨! + + +# 수정 - 객체로 변경 +image = session.get(Image, 1) +new_project = session.get(Project, 2) +image.project = new_project # 객체로 변경 +session.commit() + +print(image.project_id) # 자동으로 2로 변경됨! + + +# 조회 - 객체 직접 접근 +image = session.get(Image, 1) +print(image.project.store_name) # 바로 객체 속성 접근 +``` + +**특징:** +- 객체 지향적 접근 +- FK 값 자동 동기화 +- 양방향 자동 업데이트 + +### 5.4 양방향 동기화 동작 + +```python +# back_populates의 핵심: 양쪽 자동 동기화 + +project = Project(store_name="카페") +image = Image(original_filename="photo.jpg") + +# 방법 1: 부모에 자식 추가 +project.images.append(image) +print(image.project) # - 자동 설정! +print(image.project_id) # None (아직 flush 전) + +session.add(project) +session.flush() +print(image.project_id) # 1 - flush 후 FK 설정됨 + + +# 방법 2: 자식에 부모 지정 +image2 = Image(original_filename="logo.png") +image2.project = project +print(image2 in project.images) # True - 자동 추가! +``` + +### 5.5 비교 표 + +| 상황 | FK 필드 사용 | relationship 사용 | +|------|-------------|------------------| +| **부모 ID만 필요** | ✅ `image.project_id` | ⚠️ 불필요한 객체 로딩 가능 | +| **부모 객체 접근** | ❌ 추가 쿼리 필요 | ✅ `image.project.name` | +| **자식 추가** | `image.project_id = 1` | `project.images.append(image)` | +| **일괄 생성** | FK 값 직접 지정 | 객체 연결로 자동 설정 | +| **양방향 동기화** | ❌ 수동 관리 | ✅ 자동 | + +### 5.6 공식 권장 사용 방식 + +```python +class Image(Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + + # 1. FK 필드 - DB 레벨 관계 (필수) + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + + # 2. relationship - ORM 레벨 편의 기능 (권장) + project: Mapped["Project"] = relationship("Project", back_populates="images") + + +# 사용 시: +# - 단순 FK 값 조회/설정: project_id 사용 +# - 객체 조작/탐색: project (relationship) 사용 + +image = session.get(Image, 1) +print(image.project_id) # 빠름 (추가 쿼리 없음) +print(image.project.store_name) # 객체 접근 (필요시 쿼리) +``` + +--- + +## 6. relationship 옵션 상세 + +### 6.1 필수/권장 옵션 + +#### 첫 번째 인자: 대상 클래스 + +```python +# 문자열 (Forward Reference) - 권장 +relationship("Project") + +# 클래스 직접 참조 (순환 import 주의) +relationship(Project) +``` + +#### back_populates (양방향 연결) + +```python +# 부모 측 +images: Mapped[list["Image"]] = relationship( + "Image", + back_populates="project" # Image.project와 연결 +) + +# 자식 측 +project: Mapped["Project"] = relationship( + "Project", + back_populates="images" # Project.images와 연결 +) +``` + +### 6.2 cascade 옵션 + +자식 객체의 생명주기 관리. **부모 측에서 정의**합니다. + +```python +images: Mapped[list["Image"]] = relationship( + "Image", + cascade="all, delete-orphan", # 가장 일반적 +) +``` + +#### cascade 값 종류 + +| 값 | 설명 | +|----|------| +| `save-update` | 부모 저장 시 자식도 저장 (기본값에 포함) | +| `merge` | 부모 merge 시 자식도 merge | +| `expunge` | 부모 expunge 시 자식도 expunge | +| `delete` | 부모 삭제 시 자식도 삭제 | +| `delete-orphan` | 부모에서 분리된 자식 삭제 | +| `refresh-expire` | 부모 refresh 시 자식도 refresh | +| `all` | 위 모든 것 (delete-orphan 제외) | + +#### 일반적인 조합 + +```python +# 1. 기본값 (자동 적용) +cascade="save-update, merge" + +# 2. 부모 삭제 시 자식도 삭제 +cascade="all, delete" + +# 3. 부모에서 분리 시에도 삭제 (가장 엄격) +cascade="all, delete-orphan" + +# 4. 삭제 방지 (자식은 독립적) +cascade="save-update, merge" # delete 없음 +``` + +#### cascade 동작 예시 + +```python +class Project(Base): + images: Mapped[list["Image"]] = relationship( + "Image", + cascade="all, delete-orphan", + ) + +# delete-orphan 동작 +project = session.get(Project, 1) +image = project.images[0] +project.images.remove(image) # 부모에서 분리 +session.commit() +# → image가 DB에서도 삭제됨 (orphan이 됨) + +# delete 동작 +project = session.get(Project, 1) +session.delete(project) +session.commit() +# → 모든 project.images도 삭제됨 +``` + +### 6.3 lazy 옵션 (로딩 전략) + +관계 데이터를 언제/어떻게 로딩할지 결정합니다. + +```python +images: Mapped[list["Image"]] = relationship( + "Image", + lazy="selectin", # 로딩 전략 +) +``` + +#### lazy 값 종류 + +| 값 | 로딩 시점 | 쿼리 방식 | 사용 상황 | +|----|----------|----------|----------| +| `select` | 접근 시 (기본값) | 개별 SELECT | 거의 사용 안 함 | +| `selectin` | 부모 로딩 후 | SELECT ... IN | **1:N 권장** | +| `joined` | 부모와 함께 | JOIN | 1:1, N:1 권장 | +| `subquery` | 부모 로딩 후 | 서브쿼리 | 복잡한 경우 | +| `raise` | 접근 시 에러 | - | 명시적 로딩 강제 | +| `noload` | 로딩 안 함 | - | 특수 상황 | +| `dynamic` | Query 객체 반환 | - | 대량 데이터 | +| `write_only` | 쓰기 전용 | - | 대량 데이터 (2.0) | + +#### 로딩 전략 예시 + +```python +# 1. Lazy Loading (기본값) - N+1 문제 발생! +class Project(Base): + images: Mapped[list["Image"]] = relationship("Image", lazy="select") + +projects = session.scalars(select(Project)).all() +for project in projects: + print(project.images) # 매번 쿼리 발생! (N+1) + + +# 2. selectin - 권장 (1:N) +class Project(Base): + images: Mapped[list["Image"]] = relationship("Image", lazy="selectin") + +projects = session.scalars(select(Project)).all() +# 쿼리 1: SELECT * FROM project +# 쿼리 2: SELECT * FROM image WHERE project_id IN (1, 2, 3, ...) +for project in projects: + print(project.images) # 추가 쿼리 없음 + + +# 3. joined - 권장 (1:1, N:1) +class Image(Base): + project: Mapped["Project"] = relationship("Project", lazy="joined") + +images = session.scalars(select(Image)).all() +# 쿼리: SELECT image.*, project.* FROM image JOIN project ... +for image in images: + print(image.project.name) # 추가 쿼리 없음 + + +# 4. raise - 명시적 로딩 강제 +class Project(Base): + images: Mapped[list["Image"]] = relationship("Image", lazy="raise") + +project = session.get(Project, 1) +print(project.images) # 에러! 명시적 로딩 필요 +``` + +### 6.4 uselist 옵션 + +반환 타입을 리스트/단일 객체로 지정합니다. + +```python +# 기본값: True (리스트) +images: Mapped[list["Image"]] = relationship("Image", uselist=True) + +# 1:1 관계에서 단일 객체 +profile: Mapped["Profile"] = relationship("Profile", uselist=False) +``` + +### 6.5 foreign_keys 옵션 + +여러 FK가 있을 때 명시적 지정이 필요합니다. + +```python +class Message(Base): + sender_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + receiver_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + # 어떤 FK를 사용할지 명시 + sender: Mapped["User"] = relationship( + "User", + foreign_keys=[sender_id], + ) + receiver: Mapped["User"] = relationship( + "User", + foreign_keys=[receiver_id], + ) +``` + +### 6.6 primaryjoin 옵션 + +복잡한 조인 조건을 직접 정의합니다. + +```python +from sqlalchemy import and_ + +class Project(Base): + # 활성 이미지만 조회 + active_images: Mapped[list["Image"]] = relationship( + "Image", + primaryjoin=lambda: and_( + Project.id == Image.project_id, + Image.is_active == True + ), + viewonly=True, + ) +``` + +### 6.7 order_by 옵션 + +자식 컬렉션의 기본 정렬 순서를 지정합니다. + +```python +images: Mapped[list["Image"]] = relationship( + "Image", + order_by="Image.created_at.desc()", # 최신순 +) + +# 또는 명시적으로 +from sqlalchemy import desc +images: Mapped[list["Image"]] = relationship( + "Image", + order_by=desc(Image.created_at), +) +``` + +### 6.8 viewonly 옵션 + +읽기 전용 관계로 지정합니다 (쓰기 비활성화). + +```python +# 통계/조회용 관계 +recent_images: Mapped[list["Image"]] = relationship( + "Image", + primaryjoin="and_(Project.id == Image.project_id, Image.created_at > func.now() - interval '7 days')", + viewonly=True, # 이 관계로는 추가/삭제 불가 +) +``` + +### 6.9 옵션 종합 표 + +| 옵션 | 기본값 | 설명 | 주로 사용 위치 | +|------|--------|------|---------------| +| `back_populates` | None | 양방향 연결 | 양쪽 | +| `cascade` | `save-update, merge` | 삭제 정책 | 부모 | +| `lazy` | `select` | 로딩 전략 | 양쪽 | +| `uselist` | True | 리스트/단일 | 1:1에서 False | +| `foreign_keys` | 자동 감지 | FK 명시 | 복수 FK 시 | +| `primaryjoin` | 자동 생성 | 조인 조건 | 복잡한 조건 | +| `order_by` | None | 정렬 순서 | 부모 | +| `viewonly` | False | 읽기 전용 | 특수 관계 | + +--- + +## 7. 관계별 정의 방법 (1:1, 1:N, N:M) + +### 7.1 1:1 (One-to-One) + +한 레코드가 다른 테이블의 한 레코드와만 연결됩니다. + +``` +┌─────────────┐ ┌─────────────┐ +│ User │────────│ Profile │ +│ (parent) │ 1:1 │ (child) │ +└─────────────┘ └─────────────┘ +``` + +#### 기본 예제 + +```python +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(50), unique=True) + + # 1:1 관계 - uselist=False + profile: Mapped["Profile"] = relationship( + "Profile", + back_populates="user", + uselist=False, # 핵심! 단일 객체 반환 + cascade="all, delete-orphan", + ) + + +class Profile(Base): + __tablename__ = "profile" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("user.id"), + unique=True # 1:1 보장 + ) + bio: Mapped[str | None] = mapped_column(Text) + + user: Mapped["User"] = relationship( + "User", + back_populates="profile", + ) +``` + +#### 1:1 핵심 포인트 + +```python +# 1. FK에 unique=True +user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), unique=True) + +# 2. 부모 relationship에 uselist=False +profile: Mapped["Profile"] = relationship("Profile", uselist=False) +``` + +#### 사용 예시 + +```python +# 생성 +user = User(username="john") +user.profile = Profile(bio="Hello, I'm John") # 단일 객체 할당 +session.add(user) +session.commit() + +# 조회 +user = session.get(User, 1) +print(user.profile.bio) # 직접 접근 (리스트 아님) + +# 수정 +user.profile.bio = "Updated bio" +session.commit() + +# 교체 +user.profile = Profile(bio="New profile") # 기존 profile은 orphan 삭제 +session.commit() +``` + +### 7.2 1:N (One-to-Many) + +한 레코드가 여러 레코드와 연결됩니다. **가장 일반적인 관계입니다.** + +``` +┌─────────────┐ ┌─────────────┐ +│ Project │────────<│ Image │ +│ (parent) │ 1:N │ (child) │ +└─────────────┘ └─────────────┘ +``` + +#### 기본 예제 + +```python +class Project(Base): + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + store_name: Mapped[str] = mapped_column(String(255)) + + # 1:N 관계 - 리스트 타입 + images: Mapped[list["Image"]] = relationship( + "Image", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class Image(Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("project.id")) + filename: Mapped[str] = mapped_column(String(255)) + + # N:1 관계 + project: Mapped["Project"] = relationship( + "Project", + back_populates="images", + ) +``` + +#### 사용 예시 + +```python +# 생성 - 방법 1: 부모에 추가 +project = Project(store_name="카페") +project.images.append(Image(filename="logo.png")) +project.images.append(Image(filename="photo.jpg")) +session.add(project) +session.commit() + +# 생성 - 방법 2: 한번에 정의 +project = Project( + store_name="카페", + images=[ + Image(filename="logo.png"), + Image(filename="photo.jpg"), + ] +) +session.add(project) +session.commit() + +# 조회 +project = session.get(Project, 1) +for image in project.images: + print(image.filename) + +# 자식에서 부모 접근 +image = session.get(Image, 1) +print(image.project.store_name) + +# 자식 추가 +project.images.append(Image(filename="new.png")) +session.commit() + +# 자식 제거 (delete-orphan이면 DB에서도 삭제) +image_to_remove = project.images[0] +project.images.remove(image_to_remove) +session.commit() +``` + +### 7.3 N:M (Many-to-Many) + +양쪽 모두 여러 레코드와 연결됩니다. **연결 테이블(Association Table)이 필요합니다.** + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Post │────────<│ post_tag │>────────│ Tag │ +│ │ N:M │ (중간테이블) │ N:M │ │ +└─────────────┘ └─────────────┘ └─────────────┘ +``` + +#### 방법 1: Association Table (단순 연결) + +```python +from sqlalchemy import Table, Column, Integer, ForeignKey + +# 중간 테이블 정의 (모델 클래스 없이) +post_tag = Table( + "post_tag", + Base.metadata, + Column("post_id", Integer, ForeignKey("post.id"), primary_key=True), + Column("tag_id", Integer, ForeignKey("tag.id"), primary_key=True), +) + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + + # N:M 관계 - secondary로 중간 테이블 지정 + tags: Mapped[list["Tag"]] = relationship( + "Tag", + secondary=post_tag, # 중간 테이블 + back_populates="posts", + ) + + +class Tag(Base): + __tablename__ = "tag" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True) + + posts: Mapped[list["Post"]] = relationship( + "Post", + secondary=post_tag, + back_populates="tags", + ) +``` + +#### N:M 사용 예시 + +```python +# 생성 +post = Post(title="Python 팁") +tag1 = Tag(name="python") +tag2 = Tag(name="tutorial") + +post.tags.append(tag1) +post.tags.append(tag2) +session.add(post) +session.commit() + +# 또는 한번에 +post = Post( + title="Python 팁", + tags=[Tag(name="python"), Tag(name="tutorial")] +) + +# 조회 +post = session.get(Post, 1) +for tag in post.tags: + print(tag.name) + +tag = session.get(Tag, 1) +for post in tag.posts: + print(post.title) + +# 태그 추가/제거 +post.tags.append(existing_tag) +post.tags.remove(tag_to_remove) # 중간 테이블에서만 삭제 +session.commit() +``` + +#### 방법 2: Association Object (추가 데이터 필요 시) + +중간 테이블에 추가 컬럼이 필요한 경우 사용합니다. + +```python +class PostTag(Base): + """중간 테이블 - 추가 데이터 포함""" + __tablename__ = "post_tag" + + post_id: Mapped[int] = mapped_column(ForeignKey("post.id"), primary_key=True) + tag_id: Mapped[int] = mapped_column(ForeignKey("tag.id"), primary_key=True) + + # 추가 데이터 + created_at: Mapped[datetime] = mapped_column(default=func.now()) + created_by: Mapped[int | None] = mapped_column(ForeignKey("user.id")) + + # 양쪽 관계 + post: Mapped["Post"] = relationship("Post", back_populates="post_tags") + tag: Mapped["Tag"] = relationship("Tag", back_populates="post_tags") + + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + + post_tags: Mapped[list["PostTag"]] = relationship( + "PostTag", + back_populates="post", + cascade="all, delete-orphan", + ) + + # 편의를 위한 프로퍼티 + @property + def tags(self) -> list["Tag"]: + return [pt.tag for pt in self.post_tags] + + +class Tag(Base): + __tablename__ = "tag" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50)) + + post_tags: Mapped[list["PostTag"]] = relationship( + "PostTag", + back_populates="tag", + ) +``` + +#### Association Object 사용 예시 + +```python +# 생성 - 추가 데이터 포함 +post = Post(title="Python 팁") +tag = Tag(name="python") + +post_tag = PostTag(tag=tag, created_by=current_user.id) +post.post_tags.append(post_tag) + +session.add(post) +session.commit() + +# 조회 - 추가 데이터 접근 +for pt in post.post_tags: + print(f"Tag: {pt.tag.name}, Added: {pt.created_at}") +``` + +### 7.4 관계 비교 표 + +| 관계 | FK 위치 | 부모 타입 | 자식 타입 | 특수 설정 | +|------|---------|----------|----------|----------| +| **1:1** | 자식 | `Mapped["Child"]` | `Mapped["Parent"]` | `uselist=False`, `unique=True` | +| **1:N** | 자식 | `Mapped[list["Child"]]` | `Mapped["Parent"]` | 기본 설정 | +| **N:M** | 중간 테이블 | `Mapped[list["Other"]]` | `Mapped[list["Other"]]` | `secondary=` | + +--- + +## 8. ORM 사용법 + +### 8.1 생성 (Create) + +```python +# ───────────────────────────────────────────────────── +# 방법 1: relationship으로 연결 +# ───────────────────────────────────────────────────── +project = Project(store_name="카페") + +# append로 추가 +project.images.append(Image(filename="logo.png")) +project.images.append(Image(filename="photo.jpg")) + +session.add(project) # project만 add해도 images도 저장됨 +session.commit() + + +# ───────────────────────────────────────────────────── +# 방법 2: 생성자에서 한번에 +# ───────────────────────────────────────────────────── +project = Project( + store_name="카페", + images=[ + Image(filename="logo.png"), + Image(filename="photo.jpg"), + ] +) +session.add(project) +session.commit() + + +# ───────────────────────────────────────────────────── +# 방법 3: FK 직접 지정 +# ───────────────────────────────────────────────────── +project = Project(store_name="카페") +session.add(project) +session.flush() # ID 생성 + +image = Image(project_id=project.id, filename="logo.png") +session.add(image) +session.commit() +``` + +### 8.2 조회 (Read) + +```python +# ───────────────────────────────────────────────────── +# 기본 조회 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +print(project.images) # lazy 설정에 따라 로딩 + + +# ───────────────────────────────────────────────────── +# Eager Loading (명시적) +# ───────────────────────────────────────────────────── +from sqlalchemy.orm import selectinload, joinedload + +# selectinload - 1:N에 권장 +stmt = ( + select(Project) + .options(selectinload(Project.images)) + .where(Project.id == 1) +) +project = session.scalar(stmt) + +# joinedload - 1:1, N:1에 권장 +stmt = ( + select(Image) + .options(joinedload(Image.project)) + .where(Image.id == 1) +) +image = session.scalar(stmt) + +# 중첩 로딩 +stmt = ( + select(Project) + .options( + selectinload(Project.lyrics) + .selectinload(Lyric.songs) + ) +) + + +# ───────────────────────────────────────────────────── +# 필터링과 함께 +# ───────────────────────────────────────────────────── +# 특정 조건의 자식을 가진 부모 +stmt = ( + select(Project) + .join(Project.images) + .where(Image.filename.like("%.png")) + .distinct() +) + +# 자식 개수와 함께 +from sqlalchemy import func + +stmt = ( + select(Project, func.count(Image.id).label("image_count")) + .join(Project.images, isouter=True) + .group_by(Project.id) +) +``` + +### 8.3 수정 (Update) + +```python +# ───────────────────────────────────────────────────── +# 자식 추가 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +project.images.append(Image(filename="new.png")) +session.commit() + + +# ───────────────────────────────────────────────────── +# 자식 수정 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +project.images[0].filename = "updated.png" +session.commit() + + +# ───────────────────────────────────────────────────── +# 부모 변경 (relationship 사용) +# ───────────────────────────────────────────────────── +image = session.get(Image, 1) +new_project = session.get(Project, 2) +image.project = new_project # FK 자동 업데이트 +session.commit() + + +# ───────────────────────────────────────────────────── +# 부모 변경 (FK 직접 사용) +# ───────────────────────────────────────────────────── +image = session.get(Image, 1) +image.project_id = 2 +session.commit() +``` + +### 8.4 삭제 (Delete) + +```python +# ───────────────────────────────────────────────────── +# 부모 삭제 (cascade 동작) +# ───────────────────────────────────────────────────── +# cascade="all, delete-orphan" 설정 시 +project = session.get(Project, 1) +session.delete(project) +session.commit() +# → 모든 images도 삭제됨 + + +# ───────────────────────────────────────────────────── +# 자식만 삭제 +# ───────────────────────────────────────────────────── +image = session.get(Image, 1) +session.delete(image) +session.commit() + + +# ───────────────────────────────────────────────────── +# 부모에서 분리 (delete-orphan 시 삭제됨) +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +image = project.images[0] +project.images.remove(image) +session.commit() +# → delete-orphan이면 image도 DB에서 삭제 + + +# ───────────────────────────────────────────────────── +# 자식 전체 교체 +# ───────────────────────────────────────────────────── +project = session.get(Project, 1) +project.images = [Image(filename="new1.png"), Image(filename="new2.png")] +session.commit() +# → 기존 images는 orphan이 되어 삭제됨 (delete-orphan 시) +``` + +--- + +## 9. 실무 패턴 + +### 9.1 표준 모델 템플릿 + +```python +from datetime import datetime +from typing import List +from sqlalchemy import String, Text, ForeignKey, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + """생성/수정 시간 공통 믹스인""" + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), + onupdate=func.now() + ) + + +class Project(TimestampMixin, Base): + __tablename__ = "project" + + id: Mapped[int] = mapped_column(primary_key=True) + store_name: Mapped[str] = mapped_column(String(255)) + task_id: Mapped[str] = mapped_column(String(36), unique=True, index=True) + + # 1:N 관계들 + images: Mapped[List["Image"]] = relationship( + "Image", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + order_by="Image.created_at.desc()", + ) + + lyrics: Mapped[List["Lyric"]] = relationship( + "Lyric", + back_populates="project", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"" + + +class Image(TimestampMixin, Base): + __tablename__ = "image" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column( + ForeignKey("project.id", ondelete="CASCADE"), + index=True, + ) + original_filename: Mapped[str] = mapped_column(String(255)) + stored_filename: Mapped[str] = mapped_column(String(255)) + url: Mapped[str] = mapped_column(Text) + + # N:1 관계 + project: Mapped["Project"] = relationship( + "Project", + back_populates="images", + ) + + def __repr__(self) -> str: + return f"" +``` + +### 9.2 서비스 레이어 패턴 + +```python +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + + +class ProjectService: + def __init__(self, session: Session): + self.session = session + + def create_with_images( + self, + store_name: str, + task_id: str, + image_data: list[dict] + ) -> Project: + """프로젝트와 이미지를 함께 생성""" + project = Project( + store_name=store_name, + task_id=task_id, + images=[Image(**data) for data in image_data], + ) + self.session.add(project) + self.session.commit() + self.session.refresh(project) + return project + + def get_with_images(self, project_id: int) -> Project | None: + """프로젝트와 이미지를 함께 조회""" + stmt = ( + select(Project) + .options(selectinload(Project.images)) + .where(Project.id == project_id) + ) + return self.session.scalar(stmt) + + def get_by_task_id(self, task_id: str) -> Project | None: + """task_id로 조회""" + stmt = ( + select(Project) + .options(selectinload(Project.images)) + .where(Project.task_id == task_id) + ) + return self.session.scalar(stmt) + + def add_image(self, project_id: int, image: Image) -> Image: + """기존 프로젝트에 이미지 추가""" + project = self.session.get(Project, project_id) + if not project: + raise ValueError("Project not found") + + project.images.append(image) + self.session.commit() + self.session.refresh(image) + return image + + def delete(self, project_id: int) -> bool: + """프로젝트 삭제 (이미지도 cascade 삭제)""" + project = self.session.get(Project, project_id) + if not project: + return False + + self.session.delete(project) + self.session.commit() + return True +``` + +### 9.3 FastAPI 엔드포인트 패턴 + +```python +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +@router.post("/", response_model=ProjectResponse) +def create_project( + data: ProjectCreate, + session: Session = Depends(get_session), +): + service = ProjectService(session) + project = service.create_with_images( + store_name=data.store_name, + task_id=data.task_id, + image_data=[img.model_dump() for img in data.images], + ) + return project + + +@router.get("/{project_id}", response_model=ProjectWithImagesResponse) +def get_project( + project_id: int, + session: Session = Depends(get_session), +): + service = ProjectService(session) + project = service.get_with_images(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +@router.post("/{project_id}/images", response_model=ImageResponse) +def add_image( + project_id: int, + data: ImageCreate, + session: Session = Depends(get_session), +): + service = ProjectService(session) + image = Image(**data.model_dump()) + return service.add_image(project_id, image) +``` + +--- + +## 10. Quick Reference + +### 10.1 relationship 정의 체크리스트 + +```python +# ✅ 부모 (1 쪽) +children: Mapped[list["Child"]] = relationship( + "Child", # 1. 대상 클래스 + back_populates="parent", # 2. 반대편 속성명 + cascade="all, delete-orphan", # 3. 삭제 정책 + lazy="selectin", # 4. 로딩 전략 +) + +# ✅ 자식 (N 쪽) +parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) # FK 필수! +parent: Mapped["Parent"] = relationship( + "Parent", # 1. 대상 클래스 + back_populates="children", # 2. 반대편 속성명 +) +``` + +### 10.2 관계별 빠른 참조 + +```python +# 1:1 +# 부모: uselist=False +# 자식 FK: unique=True + +# 1:N +# 부모: list["Child"] +# 자식: "Parent" + +# N:M +# secondary=association_table +# 또는 Association Object 패턴 +``` + +### 10.3 자주 쓰는 옵션 조합 + +```python +# 기본 1:N (부모 측) +cascade="all, delete-orphan", lazy="selectin" + +# 기본 N:1 (자식 측) +# 옵션 없이 back_populates만 + +# 1:1 (부모 측) +uselist=False, cascade="all, delete-orphan" + +# 읽기 전용 관계 +viewonly=True + +# 복수 FK +foreign_keys=[column] +``` + +### 10.4 흔한 실수 + +```python +# ❌ 잘못된 예 +class Parent(Base): + children = relationship("Child") # Mapped 타입 힌트 없음 + +class Child(Base): + parent_id = Column(Integer) # ForeignKey 없음 + parent = relationship("Parent", back_populates="childs") # 오타 + + +# ✅ 올바른 예 +class Parent(Base): + children: Mapped[list["Child"]] = relationship("Child", back_populates="parent") + +class Child(Base): + parent_id: Mapped[int] = mapped_column(ForeignKey("parent.id")) + parent: Mapped["Parent"] = relationship("Parent", back_populates="children") +``` + +--- + +## 부록: 참고 자료 + +- [SQLAlchemy 2.0 공식 문서](https://docs.sqlalchemy.org/en/20/) +- [SQLAlchemy Relationship Configuration](https://docs.sqlalchemy.org/en/20/orm/relationships.html) +- [SQLAlchemy Basic Relationship Patterns](https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html) diff --git a/o2o_castad_backend.egg-info/PKG-INFO b/o2o_castad_backend.egg-info/PKG-INFO index 3a7dbfb..8611c3d 100644 --- a/o2o_castad_backend.egg-info/PKG-INFO +++ b/o2o_castad_backend.egg-info/PKG-INFO @@ -4,6 +4,8 @@ Version: 0.1.0 Summary: Add your description here Requires-Python: >=3.13 Description-Content-Type: text/markdown +Requires-Dist: aiofiles>=25.1.0 +Requires-Dist: aiohttp>=3.13.2 Requires-Dist: aiomysql>=0.3.2 Requires-Dist: asyncmy>=0.2.10 Requires-Dist: fastapi-cli>=0.0.16 @@ -15,3 +17,4 @@ Requires-Dist: ruff>=0.14.9 Requires-Dist: scalar-fastapi>=1.5.0 Requires-Dist: sqladmin[full]>=0.22.0 Requires-Dist: sqlalchemy[asyncio]>=2.0.45 +Requires-Dist: uuid7>=0.1.0 diff --git a/o2o_castad_backend.egg-info/SOURCES.txt b/o2o_castad_backend.egg-info/SOURCES.txt index 355cb02..aff0bfe 100644 --- a/o2o_castad_backend.egg-info/SOURCES.txt +++ b/o2o_castad_backend.egg-info/SOURCES.txt @@ -25,7 +25,9 @@ app/home/api/home_admin.py app/home/api/routers/__init__.py app/home/api/routers/v1/__init__.py app/home/api/routers/v1/home.py +app/home/api/routers/v1/sample.py app/home/schemas/__init__.py +app/home/schemas/home.py app/home/services/__init__.py app/home/services/base.py app/home/tests/__init__.py @@ -74,6 +76,7 @@ app/song/worker/__init__.py app/utils/__init__.py app/utils/chatgpt_prompt.py app/utils/cors.py +app/utils/nvMapScraper.py app/video/__init__.py app/video/dependencies.py app/video/models.py diff --git a/o2o_castad_backend.egg-info/requires.txt b/o2o_castad_backend.egg-info/requires.txt index 659cbd7..c4918c8 100644 --- a/o2o_castad_backend.egg-info/requires.txt +++ b/o2o_castad_backend.egg-info/requires.txt @@ -1,3 +1,5 @@ +aiofiles>=25.1.0 +aiohttp>=3.13.2 aiomysql>=0.3.2 asyncmy>=0.2.10 fastapi-cli>=0.0.16 @@ -9,3 +11,4 @@ ruff>=0.14.9 scalar-fastapi>=1.5.0 sqladmin[full]>=0.22.0 sqlalchemy[asyncio]>=2.0.45 +uuid7>=0.1.0 diff --git a/pyproject.toml b/pyproject.toml index 7464bc4..e4443c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "aiofiles>=25.1.0", + "aiohttp>=3.13.2", "aiomysql>=0.3.2", "asyncmy>=0.2.10", "fastapi-cli>=0.0.16", @@ -16,6 +18,7 @@ dependencies = [ "scalar-fastapi>=1.5.0", "sqladmin[full]>=0.22.0", "sqlalchemy[asyncio]>=2.0.45", + "uuid7>=0.1.0", ] [dependency-groups] @@ -30,4 +33,4 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["app*"] -exclude = ["media*", "static*"] \ No newline at end of file +exclude = ["media*", "static*"] diff --git a/uv.lock b/uv.lock index 5ab0ccd..b121e2b 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,92 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, + { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, + { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, + { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, + { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, + { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, + { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, + { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, + { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, + { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, + { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, +] + [[package]] name = "aiomysql" version = "0.3.2" @@ -14,6 +100,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/af/aae0153c3e28712adaf462328f6c7a3c196a1c1c27b491de4377dd3e6b52/aiomysql-0.3.2-py3-none-any.whl", hash = "sha256:c82c5ba04137d7afd5c693a258bea8ead2aad77101668044143a991e04632eb2", size = 71834, upload-time = "2025-10-22T00:15:15.905Z" }, ] +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -50,6 +148,15 @@ version = "0.2.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/76/55cc0577f9e838c5a5213bf33159b9e484c9d9820a2bafd4d6bfa631bf86/asyncmy-0.2.10.tar.gz", hash = "sha256:f4b67edadf7caa56bdaf1c2e6cf451150c0a86f5353744deabe4426fe27aff4e", size = 63889, upload-time = "2024-12-12T14:45:09.2Z" } +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -228,6 +335,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "greenlet" version = "3.3.0" @@ -481,11 +661,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + [[package]] name = "o2o-castad-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, { name = "aiomysql" }, { name = "asyncmy" }, { name = "fastapi", extra = ["standard"] }, @@ -497,6 +760,7 @@ dependencies = [ { name = "scalar-fastapi" }, { name = "sqladmin", extra = ["full"] }, { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uuid7" }, ] [package.dev-dependencies] @@ -507,6 +771,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiofiles", specifier = ">=25.1.0" }, + { name = "aiohttp", specifier = ">=3.13.2" }, { name = "aiomysql", specifier = ">=0.3.2" }, { name = "asyncmy", specifier = ">=0.2.10" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" }, @@ -518,6 +784,7 @@ requires-dist = [ { name = "scalar-fastapi", specifier = ">=1.5.0" }, { name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" }, + { name = "uuid7", specifier = ">=0.1.0" }, ] [package.metadata.requires-dev] @@ -563,6 +830,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/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 = "pydantic" version = "2.12.5" @@ -1029,6 +1365,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, ] +[[package]] +name = "uuid7" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/19/7472bd526591e2192926247109dbf78692e709d3e56775792fec877a7720/uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c", size = 14052, upload-time = "2021-12-29T01:38:21.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/77/8852f89a91453956582a85024d80ad96f30a41fed4c2b3dce0c9f12ecc7e/uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61", size = 7477, upload-time = "2021-12-29T01:38:20.418Z" }, +] + [[package]] name = "uvicorn" version = "0.38.0" @@ -1167,3 +1512,81 @@ sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/96d10183c3470f183 wheels = [ { url = "https://files.pythonhosted.org/packages/18/19/c3232f35e24dccfad372e9f341c4f3a1166ae7c66e4e1351a9467c921cc1/wtforms-3.1.2-py3-none-any.whl", hash = "sha256:bf831c042829c8cdbad74c27575098d541d039b1faa74c771545ecac916f2c07", size = 145961, upload-time = "2024-01-06T07:52:43.023Z" }, ] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +]