From 7352d48a20aa11a84f98d19add4e0bfc78f568e1 Mon Sep 17 00:00:00 2001 From: esok Date: Sat, 3 Jan 2026 04:08:18 +0900 Subject: [PATCH 01/11] =?UTF-8?q?test:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=AA=85=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EC=98=A4=EB=A5=98(1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 40글자 넘어 가는 테스트 에러 2. 최소길이 4글자 이상 테스트 에러 --- .vscode/settings.json | 7 +++-- appserver/apps/account/endpoints.py | 10 +++++- appserver/apps/account/models.py | 6 ++-- tests/apps/account/test_signup.py | 49 +++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 tests/apps/account/test_signup.py diff --git a/.vscode/settings.json b/.vscode/settings.json index e43741c..32b354a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,14 @@ { "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", - "python.testing.cwd": "./", + "python.testing.cwd": "${workspaceFolder}", "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "pythonTestExplorer.testFramework": "pytest", "python.testing.pytestPath": "./.venv/Scripts/pytest.exe", "python.testing.pytestArgs": [ - "${workspaceFolder}/tests", - "${workspaceFolder}/appserver" + "${workspaceFolder}/tests" ], + "python.testing.autoTestDiscoverOnSaveEnabled": true, "python.analysis.autoImportCompletions": true, "python.analysis.indexing": true, "python.analysis.completeFunctionParens": true, @@ -25,4 +25,5 @@ "./appserver" ], "python.terminal.activateEnvironment": true, + "python-envs.pythonProjects": [], } \ No newline at end of file diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 3730777..16669a5 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -28,4 +28,12 @@ async def user_detail(username: str, session: DbSessionDep) -> User: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" - ) \ No newline at end of file + ) + +@router.post("/signup") +async def signup(payload: dict, session: DbSessionDep) -> User: + # 사용자 입력을 Pydantic 모델 규칙으로 검증하여 안전한 모델로 변환 + user = User.model_validate(payload) + session.add(user) # 세션에 모델 객체 등록 + await session.commit() # 모든 객체의 변경 사항을 데이터베이스에 반영 + return user \ No newline at end of file diff --git a/appserver/apps/account/models.py b/appserver/apps/account/models.py index f63f9d8..22b010e 100644 --- a/appserver/apps/account/models.py +++ b/appserver/apps/account/models.py @@ -20,16 +20,16 @@ class User(SQLModel, table=True): id: int = Field(default=None, primary_key=True) # username: 고유해야 하며, 최대 40자 제한, description은 Swagger 문성에 설명으로 표시됨 - username: str = Field(unique=True, max_length=40, description="사용자 계정 ID") + username: str = Field(min_length=4,unique=True, max_length=40, description="사용자 계정 ID") # email: Pydantic의 EamilStr을 사용해 문자열이 아닌 진짜 이메일 형식인지 검증함 email: EmailStr = Field(max_length=128, description="사용자 이메일") # display_name: 서비스에서 보여질 별명, 길이를 제한하여 DB 공간 효율 증대 - display_name: str = Field(max_length=40, description="사용자 표시 이름") + display_name: str = Field(min_length=4, max_length=40, description="사용자 표시 이름") # password: 실제 비밀번호가 저장될 곳, 나중에 해싱(암호화)된 문자열이 저장될 예정 - password: str = Field(max_length=128, description="사용자 비밀번호") + password: str = Field(min_length=4, max_length=128, description="사용자 비밀번호") # is_host: 호스트/게스트 구분용, 기본값 False(게스트)로 설정 is_host: bool = Field(default=False, description="사용자가 호스트인지 여부") diff --git a/tests/apps/account/test_signup.py b/tests/apps/account/test_signup.py new file mode 100644 index 0000000..f040240 --- /dev/null +++ b/tests/apps/account/test_signup.py @@ -0,0 +1,49 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.apps.account.endpoints import signup +from appserver.apps.account.models import User +from fastapi.testclient import TestClient +import pytest +from pydantic import ValidationError + +async def test_모든_입력_항목을_유효한_값으로_입력하면_계정이_생성된다( + client: TestClient, + db_session: AsyncSession + ): + payload = { + "username": "test", + "email": "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + + result = await signup(payload, db_session) + + assert isinstance(result, User) + assert result.username == payload["username"] + assert result.email == payload["email"] + assert result.display_name == payload["display_name"] + assert result.is_host is False + +@pytest.mark.parametrize( + "username", + [ + "puddingcamppuddingcamppuddingcamppuddingcamppuddingcamp", + 12345678, + "x", + ] +) + +async def test_사용자명이_유효하지_않으면_사용자명이_유효하지_않다는_메세지를_담은_오류를_일으킨다( + client: TestClient, + db_session: AsyncSession, + username: str +): + payload = { + "username": username, + "email": "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + + with pytest.raises(ValidationError) as exc_info: + await signup(payload, db_session) \ No newline at end of file From a88b26c1122cdfadeda121852dde7e70af98d57c Mon Sep 17 00:00:00 2001 From: esok Date: Sat, 3 Jan 2026 08:32:53 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DuplicatedEmailError 예외 클래스 추가 - signup 엔드포인트에 이메일 중복 검증 로직 구현 - 이메일 중복 오류 테스트 케이스 추가 - Race condition 대비 IntegrityError 처리 개선 - username과 email 중복 체크 로직 명확화" --- appserver/apps/account/endpoints.py | 30 +++++++++++++++++++++++++++- appserver/apps/account/exceptions.py | 15 ++++++++++++++ tests/apps/account/test_signup.py | 27 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 appserver/apps/account/exceptions.py diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 16669a5..7445bad 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -4,6 +4,11 @@ from appserver.db import DbSessionDep # .models에서 User 모델을 가져옴 (현재 코드에서 직접 쓰이지 않지만 확장을 위한 존재) from .models import User +# 데이터베이스 중복 확인을 위한 select, func +from sqlmodel import select, func +# 중복 사용자명 예외 +from .exceptions import DuplicatedUsernameError, DuplicatedEmailError +from sqlalchemy.exc import IntegrityError # /account 경로로 시작하는 API 그룹 생성 router = APIRouter(prefix="/account") @@ -32,8 +37,31 @@ async def user_detail(username: str, session: DbSessionDep) -> User: @router.post("/signup") async def signup(payload: dict, session: DbSessionDep) -> User: + # username 중복 체크 + stmt = select(func.count()).select_from(User).where(User.username == payload["username"]) + result = await session.execute(stmt) + count = result.scalar_one() + if count > 0: + raise DuplicatedUsernameError + + # email 중복 체크 + stmt = select(func.count()).select_from(User).where(User.email == payload["email"]) + result = await session.execute(stmt) + count = result.scalar_one() + if count > 0: + raise DuplicatedEmailError + # 사용자 입력을 Pydantic 모델 규칙으로 검증하여 안전한 모델로 변환 user = User.model_validate(payload) session.add(user) # 세션에 모델 객체 등록 - await session.commit() # 모든 객체의 변경 사항을 데이터베이스에 반영 + + try: + await session.commit() # 모든 객체의 변경 사항을 데이터베이스에 반영 + except IntegrityError as e: + # 혹시 모를 race condition 대비 + if "email" in str(e.orig): + raise DuplicatedEmailError + else: + raise DuplicatedUsernameError + return user \ No newline at end of file diff --git a/appserver/apps/account/exceptions.py b/appserver/apps/account/exceptions.py new file mode 100644 index 0000000..474b2d7 --- /dev/null +++ b/appserver/apps/account/exceptions.py @@ -0,0 +1,15 @@ +from fastapi import HTTPException, status + +class DuplicatedUsernameError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="중복된 계정 ID입니다.", + ) + +class DuplicatedEmailError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="중복된 이메일입니다.", + ) \ No newline at end of file diff --git a/tests/apps/account/test_signup.py b/tests/apps/account/test_signup.py index f040240..f26353f 100644 --- a/tests/apps/account/test_signup.py +++ b/tests/apps/account/test_signup.py @@ -4,6 +4,7 @@ from fastapi.testclient import TestClient import pytest from pydantic import ValidationError +from appserver.apps.account.exceptions import DuplicatedUsernameError, DuplicatedEmailError async def test_모든_입력_항목을_유효한_값으로_입력하면_계정이_생성된다( client: TestClient, @@ -46,4 +47,30 @@ async def test_사용자명이_유효하지_않으면_사용자명이_유효하 } with pytest.raises(ValidationError) as exc_info: + await signup(payload, db_session) + +async def test_계정_ID가_중복되면_중복_계정_ID_오류를_일으킨다(db_session: AsyncSession): + payload = { + "username" : "test", + "email" : "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + await signup(payload, db_session) + + payload["username"] = "test2" + with pytest.raises(DuplicatedUsernameError) as exc: + await signup(payload, db_session) + +async def test_e_mail_주소가_중복되면_중복_이메일_오류를_일으킨다(db_session: AsyncSession): + payload = { + "username": "test", + "email": "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + await signup(payload, db_session) + + payload["username"] = "test2" + with pytest.raises(DuplicatedEmailError) as exc: await signup(payload, db_session) \ No newline at end of file From 9b21aa8f5c2892cddce510a5b7c4a73dedfa607b Mon Sep 17 00:00:00 2001 From: esok Date: Sun, 4 Jan 2026 14:36:39 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appserver/apps/account/models.py | 23 ++++++++++++++++++++++- tests/apps/account/test_signup.py | 12 +++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/appserver/apps/account/models.py b/appserver/apps/account/models.py index 22b010e..dd52a63 100644 --- a/appserver/apps/account/models.py +++ b/appserver/apps/account/models.py @@ -4,6 +4,9 @@ from sqlalchemy import UniqueConstraint # 특정 컬럼의 값이 중복되지 않도록 DB 레벨에서 강제하는 from sqlalchemy_utc import UtcDateTime from typing import TYPE_CHECKING, Union +import random +import string +from pydantic import model_validator if TYPE_CHECKING: from appserver.apps.calendar.models import Calendar, Booking @@ -63,7 +66,25 @@ class User(SQLModel, table=True): sa_relationship_kwargs={"uselist": False, "single_parent": True}, ) bookings: list["Booking"] = Relationship(back_populates="guest") - + + @model_validator(mode="before") # 데이터 검증 전에 실행, Pydantic 모델 생성 전에 실행 + @classmethod + def generate_display_name(cls, data: dict): + if not data.get("display_name"): + data["display_name"] = "".join( + random.choices( + string.ascii_letters + string.digits, + k=8, + ) + ) + return data + + # @model_validator(mode="after") + # pydantic이 데이터를 검증하고 모델 객체를 만들기 직전에 이 함수를 실행하라는 뜻 + # mode = "after" 일 때는 입력받은 데이터가 아직 딕셔너리 형태입니다. 데이터 타입을 맞추거나, + # 빠진 값을 채워넣는 전처리 단계에서 주로 사용 + # 함수가 특정 인스턴스가 아니라 클래스 자체에 속함 + class OAuthAccount(SQLModel, table=True): __tablename__ = "oauth_accounts" __table_args__ = ( diff --git a/tests/apps/account/test_signup.py b/tests/apps/account/test_signup.py index f26353f..2992be9 100644 --- a/tests/apps/account/test_signup.py +++ b/tests/apps/account/test_signup.py @@ -73,4 +73,14 @@ async def test_e_mail_주소가_중복되면_중복_이메일_오류를_일으 payload["username"] = "test2" with pytest.raises(DuplicatedEmailError) as exc: - await signup(payload, db_session) \ No newline at end of file + await signup(payload, db_session) + +async def test_표시명을_입력하지_않으면_무작위_문자열_8글자로_대신한다(db_session: AsyncSession): + payload = { + "username": "test", + "email": "test@example.com", + "password": "test테스트1234", + } + user = await signup(payload, db_session) + assert isinstance(user.display_name, str) + assert len(user.display_name) == 8 From e4d0abe0775204891d4a1d9936e36ea910f6c80f Mon Sep 17 00:00:00 2001 From: esok Date: Sun, 4 Jan 2026 18:36:47 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appserver/apps/account/endpoints.py | 62 +++++++++++++++++++-------- appserver/apps/account/models.py | 1 - appserver/apps/account/schemas.py | 53 +++++++++++++++++++++++ pyproject.toml | 3 +- tests/apps/account/test_signup_api.py | 36 ++++++++++++++++ 5 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 appserver/apps/account/schemas.py create mode 100644 tests/apps/account/test_signup_api.py diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 7445bad..ac7b392 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -9,6 +9,7 @@ # 중복 사용자명 예외 from .exceptions import DuplicatedUsernameError, DuplicatedEmailError from sqlalchemy.exc import IntegrityError +from .schemas import SignupPayload, UserOut # /account 경로로 시작하는 API 그룹 생성 router = APIRouter(prefix="/account") @@ -35,33 +36,58 @@ async def user_detail(username: str, session: DbSessionDep) -> User: detail="User not found" ) -@router.post("/signup") -async def signup(payload: dict, session: DbSessionDep) -> User: - # username 중복 체크 - stmt = select(func.count()).select_from(User).where(User.username == payload["username"]) - result = await session.execute(stmt) - count = result.scalar_one() +# ============ 회원가입 API ============ +# response_model=UserOut: 응답 시 UserOut 스키마로 필터링 (password 등 민감정보 제외) +# status_code=201: 리소스 생성 성공 시 HTTP 201 반환 +@router.post("/signup", status_code=status.HTTP_201_CREATED, response_model=UserOut) +async def signup(payload: SignupPayload, session: DbSessionDep) -> User: + """ + 회원가입 엔드포인트. + + 처리 순서: + 1. 입력 검증 (SignupPayload에서 자동 처리) + 2. username 중복 체크 + 3. email 중복 체크 + 4. User 모델로 변환 및 DB 저장 + 5. UserOut 형태로 응답 (민감정보 제외) + """ + # ========== 1. username 중복 체크 ========== + # func.count(): SQL의 COUNT(*) 함수 + # select_from(User): User 테이블에서 조회 + # where(): 조건 필터링 (SQL의 WHERE절) + stmt = select(func.count()).select_from(User).where(User.username == payload.username) + result = await session.execute(stmt) # 비동기로 쿼리 실행 + count = result.scalar_one() # 단일 값(숫자) 추출 if count > 0: - raise DuplicatedUsernameError + raise DuplicatedUsernameError() # 커스텀 예외 발생 - # email 중복 체크 - stmt = select(func.count()).select_from(User).where(User.email == payload["email"]) + # ========== 2. email 중복 체크 ========== + stmt = select(func.count()).select_from(User).where(User.email == payload.email) result = await session.execute(stmt) count = result.scalar_one() if count > 0: - raise DuplicatedEmailError + raise DuplicatedEmailError() - # 사용자 입력을 Pydantic 모델 규칙으로 검증하여 안전한 모델로 변환 - user = User.model_validate(payload) - session.add(user) # 세션에 모델 객체 등록 + # ========== 3. 데이터 변환 및 저장 ========== + # payload.model_dump(): SignupPayload 객체 → dict 변환 + # User.model_validate(): dict → User 모델 변환 (검증 포함) + # from_attributes=True: 객체의 속성에서도 값을 가져올 수 있게 함 + user = User.model_validate(payload.model_dump(), from_attributes=True) + session.add(user) # 세션에 추가 (아직 DB에 저장 안 됨) try: - await session.commit() # 모든 객체의 변경 사항을 데이터베이스에 반영 + # commit(): 세션의 모든 변경사항을 DB에 실제로 반영 + # 이 시점에 INSERT 쿼리가 실행됨 + await session.commit() except IntegrityError as e: - # 혹시 모를 race condition 대비 + # Race Condition 대비: 중복 체크와 저장 사이에 다른 요청이 끼어들 수 있음 + # DB의 UNIQUE 제약조건 위반 시 IntegrityError 발생 if "email" in str(e.orig): - raise DuplicatedEmailError + raise DuplicatedEmailError() else: - raise DuplicatedUsernameError + raise DuplicatedUsernameError() - return user \ No newline at end of file + # ========== 4. 응답 반환 ========== + # User 객체를 반환하지만, response_model=UserOut 때문에 + # 실제로는 UserOut 형태로 필터링되어 응답됨 (password 제외) + return user diff --git a/appserver/apps/account/models.py b/appserver/apps/account/models.py index dd52a63..5da6d3b 100644 --- a/appserver/apps/account/models.py +++ b/appserver/apps/account/models.py @@ -78,7 +78,6 @@ def generate_display_name(cls, data: dict): ) ) return data - # @model_validator(mode="after") # pydantic이 데이터를 검증하고 모델 객체를 만들기 직전에 이 함수를 실행하라는 뜻 # mode = "after" 일 때는 입력받은 데이터가 아직 딕셔너리 형태입니다. 데이터 타입을 맞추거나, diff --git a/appserver/apps/account/schemas.py b/appserver/apps/account/schemas.py new file mode 100644 index 0000000..9bcbcd3 --- /dev/null +++ b/appserver/apps/account/schemas.py @@ -0,0 +1,53 @@ +import random +import string +from typing_extensions import Self # Python 3.10 호환성 +from pydantic import model_validator, EmailStr +from sqlmodel import SQLModel, Field + +# ============ 회원가입 입력 스키마 ============ +# 클라이언트로부터 받는 회원가입 데이터의 형식을 정의 +# Field()로 각 필드의 검증 규칙 설정 +class SignupPayload(SQLModel): + username: str = Field(min_length=4, unique=True, max_length=40, description="사용자 계정 ID") + email: EmailStr = Field(unique=True, max_length=128, description="사용자 이메일") # EmailStr: 이메일 형식 자동 검증 + display_name: str = Field(min_length=4, max_length=40, description="사용자 표시 이름") + password: str = Field(min_length=8, max_length=128, description="사용자 비밀번호") + password_again: str = Field(min_length=8, max_length=128, description="사용자 비밀번호 확인") + + # ========== Validator 1: 비밀번호 일치 검증 ========== + # mode="after": 모든 필드 검증이 끝난 후 실행 (이미 SignupPayload 객체가 생성된 상태) + # Self: 자기 자신의 타입(SignupPayload)을 반환한다는 의미 + @model_validator(mode="after") + def verify_password(self) -> Self: + if self.password != self.password_again: + raise ValueError("Passwords do not match") + return self + + # ========== Validator 2: display_name 자동 생성 ========== + # mode="before": 필드 검증 전에 실행 (아직 dict 상태) + # @classmethod: 인스턴스가 아닌 클래스 자체를 받음 (cls = SignupPayload) + @model_validator(mode="before") + @classmethod + def generate_display_name(cls, data: dict) -> dict: + """display_name이 없으면 랜덤 8자리 문자열 생성.""" + if not data.get("display_name"): + # string.ascii_letters: 'abcd...xyzABC...XYZ' + # string.digits: '0123456789' + # random.choices(pool, k=8): pool에서 8개 랜덤 선택 (중복 허용) + data["display_name"] = "".join( + random.choices( + string.ascii_letters + string.digits, + k=8, + ) + ) + return data + +# ============ 회원가입 응답 스키마 ============ +# API 응답 시 클라이언트에게 보여줄 필드만 정의 +# password, email 등 민감정보는 제외 +class UserOut(SQLModel): + username: str + display_name: str + is_host: bool + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4d235e3..2cfc01d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ dependencies = [ "sqlalchemy-utc (>=0.14.0,<0.15.0)", "aiosqlite (>=0.22.1,<0.23.0)", "alembic (>=1.17.2,<2.0.0)", - "greenlet (>=3.3.0,<4.0.0)" + "greenlet (>=3.3.0,<4.0.0)", + "typing-extensions (>=4.0.0,<5.0.0)" ] diff --git a/tests/apps/account/test_signup_api.py b/tests/apps/account/test_signup_api.py new file mode 100644 index 0000000..b754678 --- /dev/null +++ b/tests/apps/account/test_signup_api.py @@ -0,0 +1,36 @@ +from fastapi import status +from fastapi.testclient import TestClient + +async def test_회원가입_성공(client: TestClient): + payload = { + "username": "test", + "email": "test@example.com", + "password": "test테스트1234", + "password_again": "test테스트1234", + } + + response = client.post("/account/signup", json=payload) + + data = response.json() + assert response.status_code == status.HTTP_201_CREATED + assert data["username"] == payload["username"] + assert data["email"] == payload["email"] + assert isinstance(data["display_name"], str) + assert len(data["display_name"]) == 8 + +async def test_응답_결과에는_username_display_name_is_host_만_출력환다(client: TestClient): + payload = { + "username": "puddingcamp", + "display_name": "푸딩캠프", + "email": "test@example.com", + "password": "test테스트1234", + "password_again": "test테스트1234", + } + + response = client.post("/account/signup", json=payload) + data = response.json() + assert response.status_code == status.HTTP_201_CREATED + + response_keys = frozenset(data.keys()) + expected_keys = frozenset(["username", "display_name", "is_host"]) + assert response_keys == expected_keys \ No newline at end of file From d7dbb8c9bff106f8c3e785325c6747baaad76d16 Mon Sep 17 00:00:00 2001 From: esok Date: Mon, 5 Jan 2026 05:57:08 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 2 +- appserver/apps/account/endpoints.py | 56 +++- appserver/apps/account/exceptions.py | 14 + appserver/apps/account/models.py | 3 + appserver/apps/account/schemas.py | 5 +- appserver/apps/account/utils.py | 61 +++++ poetry.lock | 365 ++++++++++++++++++++++++++- pyproject.toml | 4 +- tests/apps/account/test_endpoints.py | 1 - tests/apps/account/test_login_api.py | 23 ++ tests/conftest.py | 24 +- 11 files changed, 548 insertions(+), 10 deletions(-) create mode 100644 appserver/apps/account/utils.py create mode 100644 tests/apps/account/test_login_api.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 32b354a..9e115e0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,7 +6,7 @@ "pythonTestExplorer.testFramework": "pytest", "python.testing.pytestPath": "./.venv/Scripts/pytest.exe", "python.testing.pytestArgs": [ - "${workspaceFolder}/tests" + "." ], "python.testing.autoTestDiscoverOnSaveEnabled": true, "python.analysis.autoImportCompletions": true, diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index ac7b392..0fa363a 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -1,4 +1,10 @@ -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta +from fastapi.responses import JSONResponse +from .utils import ( + verify_password, + create_access_token, + ACCESS_TOKEN_EXPIRE_MINUTES +) from fastapi import APIRouter, HTTPException, status from sqlmodel import select, SQLModel from appserver.db import DbSessionDep @@ -10,6 +16,9 @@ from .exceptions import DuplicatedUsernameError, DuplicatedEmailError from sqlalchemy.exc import IntegrityError from .schemas import SignupPayload, UserOut +from .exceptions import PasswordMismatchError, UserNotFoundError +from .schemas import SignupPayload, UserOut, LoginPayload +from .utils import verify_password # /account 경로로 시작하는 API 그룹 생성 router = APIRouter(prefix="/account") @@ -91,3 +100,48 @@ async def signup(payload: SignupPayload, session: DbSessionDep) -> User: # User 객체를 반환하지만, response_model=UserOut 때문에 # 실제로는 UserOut 형태로 필터링되어 응답됨 (password 제외) return user + +@router.post("/login", status_code=status.HTTP_200_OK) +async def login(payload: LoginPayload, session: DbSessionDep) -> JSONResponse: + """ + 로그인 요청을 수신하고, 자격이 확인되면 토큰과 쿠키를 반환합니다. + """ + stmt = select(User).where(User.username == payload.username) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + if user is None: + raise UserNotFoundError() + + # 입력한 비밀번호를 저장된 해시와 비교해서 유효성 검사 + is_valid = verify_password(payload.password, user.hashed_password) + if not is_valid: + raise PasswordMismatchError() + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={ + "sub": user.username, + "displayname": user.display_name, + "is_host": user.is_host, + }, + expires_delta=access_token_expires, + ) + response_data = { + "access_token": access_token, + "token_type": "bearer", + "user": user.model_dump(mode="json", exclude={"hashed_password", "email"}) + } + + # 토큰 만료 시간 기준으로 쿠키도 설정하기 + now = datetime.now(timezone.utc) + + res = JSONResponse(response_data) + res.set_cookie( + key="auth_token", + value=access_token, + expires=now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + httponly=True, + secure=True, + samesite="strict" + ) + return res diff --git a/appserver/apps/account/exceptions.py b/appserver/apps/account/exceptions.py index 474b2d7..0511107 100644 --- a/appserver/apps/account/exceptions.py +++ b/appserver/apps/account/exceptions.py @@ -12,4 +12,18 @@ def __init__(self): super().__init__( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="중복된 이메일입니다.", + ) + +class UserNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + +class PasswordMismatchError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Password mismatch", ) \ No newline at end of file diff --git a/appserver/apps/account/models.py b/appserver/apps/account/models.py index 5da6d3b..7af5438 100644 --- a/appserver/apps/account/models.py +++ b/appserver/apps/account/models.py @@ -31,6 +31,9 @@ class User(SQLModel, table=True): # display_name: 서비스에서 보여질 별명, 길이를 제한하여 DB 공간 효율 증대 display_name: str = Field(min_length=4, max_length=40, description="사용자 표시 이름") + # hashed_password: 실제 비밀번호가 저장될 곳, 나중에 해싱(암호화)된 문자열이 저장될 예정 + hashed_password: str = Field(min_length=4, max_length=128, description="사용자 비밀번호") + # password: 실제 비밀번호가 저장될 곳, 나중에 해싱(암호화)된 문자열이 저장될 예정 password: str = Field(min_length=4, max_length=128, description="사용자 비밀번호") # is_host: 호스트/게스트 구분용, 기본값 False(게스트)로 설정 diff --git a/appserver/apps/account/schemas.py b/appserver/apps/account/schemas.py index 9bcbcd3..8145bf0 100644 --- a/appserver/apps/account/schemas.py +++ b/appserver/apps/account/schemas.py @@ -50,4 +50,7 @@ class UserOut(SQLModel): display_name: str is_host: bool - \ No newline at end of file +class LoginPayload(SQLModel): + """로그인 API에서 받는 페이로드""" + username: str = Field(min_length=4, max_length=40) + password: str = Field(min_length=8, max_length=128) \ No newline at end of file diff --git a/appserver/apps/account/utils.py b/appserver/apps/account/utils.py new file mode 100644 index 0000000..2affe37 --- /dev/null +++ b/appserver/apps/account/utils.py @@ -0,0 +1,61 @@ +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher +from pwdlib.hashers.bcrypt import BcryptHasher # ⚠️ 오타 주의: bcrypt +from datetime import datetime, timedelta, timezone +from jose import jwt +from typing import Any, Union + +SECRET_KEY = "your-secret-key" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +def hash_password(password: str) -> str: + """ + 비밀번호를 해시(암호화)하여 반환 + 사용자가 입력한 평문 비밀번호를 Argon2 알고리즘을 사용하여 + 복호화가 불가능한 해시 문자열로 변환 + + Args: + password(str): 평문 비밀번호 + + Returns: + str: 암호화된 비밀번호 + """ + password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) + return password_hash.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + # 1. 해쉬 검증 도구 생성 + password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) + # 2. 검증 수행 및 결과 반환 (true/false) + return password_hash.verify(plain_password, hashed_password) + +def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: + """ + JWT를 생성해 반환합니다. + + Args: + data (dict): JWT에 포함할 페이로드 정보. + expires_delta (Union[timedelta, None], optional): 만료 시간. 지정 없으면 기본값을 사용합니다. + + Returns: + str: 인코딩된 JWT 문자열. + """ + to_encode = data.copy() + now = datetime.now(timezone.utc) + if expires_delta: + expire = now + expires_delta + else: + expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +if __name__ == "__main__": + password = "dhrdmstn" + hashed_password = hash_password(password) + print("Hashed Password:", hashed_password) + + # 검증 테스트 + print("Verification:", verify_password(password, hashed_password)) diff --git a/poetry.lock b/poetry.lock index 881952e..0bf778d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -37,6 +37,63 @@ typing-extensions = ">=4.12" [package.extras] tz = ["tzdata"] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"}, + {file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520"}, + {file = "argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d"}, +] + +[package.dependencies] +cffi = [ + {version = ">=1.0.1", markers = "python_version < \"3.14\""}, + {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -50,6 +107,180 @@ files = [ {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5"}, + {file = "bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993"}, + {file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"}, + {file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"}, + {file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"}, + {file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"}, + {file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "colorama" version = "0.4.6" @@ -63,6 +294,25 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "ecdsa" +version = "0.19.1" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] +files = [ + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -303,6 +553,51 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pwdlib" +version = "0.3.0" +description = "Modern password hashing for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d"}, + {file = "pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc"}, +] + +[package.dependencies] +argon2-cffi = {version = ">=23.1.0,<26", optional = true, markers = "extra == \"argon2\""} +bcrypt = {version = ">=4.1.2,<6", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=23.1.0,<26)"] +bcrypt = ["bcrypt (>=4.1.2,<6)"] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + [[package]] name = "pygments" version = "2.19.2" @@ -363,6 +658,60 @@ typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "python-jose" +version = "3.5.0" +description = "JOSE implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, + {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, +] + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = ">=0.5.0" +rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "rsa" +version = "4.2" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "rsa" +version = "4.9.1" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "<4,>=3.6" +groups = ["main"] +markers = "python_version < \"3.14\"" +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "setuptools" version = "80.9.0" @@ -384,6 +733,18 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sqlalchemy" version = "2.0.45" @@ -555,9 +916,9 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version < \"3.13\""} +markers = {dev = "python_version < \"3.14\""} [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "09a4308827555f8bf0c6e31442a33fe1c27c5c7fe686be9a55fbf498a214e929" +content-hash = "ac9fd150d50b493b299c784f589e65012ad1dff8e3dbb2269167e767014879e3" diff --git a/pyproject.toml b/pyproject.toml index 2cfc01d..91f446d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,9 @@ dependencies = [ "aiosqlite (>=0.22.1,<0.23.0)", "alembic (>=1.17.2,<2.0.0)", "greenlet (>=3.3.0,<4.0.0)", - "typing-extensions (>=4.0.0,<5.0.0)" + "typing-extensions (>=4.0.0,<5.0.0)", + "pwdlib[argon2,bcrypt] (>=0.3.0,<0.4.0)", + "python-jose[crpytography] (>=3.5.0,<4.0.0)" ] diff --git a/tests/apps/account/test_endpoints.py b/tests/apps/account/test_endpoints.py index 651164c..bcf1b41 100644 --- a/tests/apps/account/test_endpoints.py +++ b/tests/apps/account/test_endpoints.py @@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from appserver.db import create_async_engine, create_session - async def test_user_detail_successfully(db_session: AsyncSession): """정상적인 사용자 조회 시 데이터가 정확한지 확인""" host_user = User( diff --git a/tests/apps/account/test_login_api.py b/tests/apps/account/test_login_api.py new file mode 100644 index 0000000..1f93d0c --- /dev/null +++ b/tests/apps/account/test_login_api.py @@ -0,0 +1,23 @@ +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.schemas import LoginPayload +from appserver.apps.account.models import User + +async def test_로그인_성공(host_user: User, client: TestClient): + payload = LoginPayload.model_validate({ + "username": host_user.username, + "password": "testtest", + }) + + response = client.post("/account/login", json=payload.model_dump()) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["access_token"] is not None + assert data["token_type"] == "bearer" + assert data["user"]["username"] == host_user.username + assert data["user"]["display_name"] == host_user.display_name + assert data["user"]["is_host"] == host_user.is_host + cookie = response.cookies.get("auth_token") + assert cookie is not None + assert cookie == data["access_token"] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index e06adea..46c7cd9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,15 +2,15 @@ import asyncio from appserver.db import create_async_engine, create_session # 모델들이 metadata에 등록되도록 반드시 import -from appserver.apps.account import models -from appserver.apps.calendar import models +from appserver.apps.account import models as account_models +from appserver.apps.calendar import models as calendar_models # ensure Calendar model is registered from sqlmodel import SQLModel from fastapi import FastAPI from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine from appserver.db import create_engine, create_session, use_session from appserver.app import include_routers from fastapi.testclient import TestClient - +from appserver.apps.account.utils import hash_password @pytest.fixture(scope="function") async def db_engine(): @@ -70,3 +70,21 @@ def client(fastapi_app: FastAPI): # TestClient는 내부적으로 비동기 함수를 동기적으로 실행해줍니다 with TestClient(fastapi_app) as client: yield client + +@pytest.fixture() +async def host_user(db_session: AsyncSession): + """ + 테스트용 호스트 사용자 fixture + """ + user = account_models.User( + username="puddingcamp", + hashed_password=hash_password("testtest"), + password=hash_password("testtest"), + email="puddingcamp@example.com", + display_name="푸딩캠프", + is_host=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.flush(user) + return user From 968dacf9b3bedb4bca2b486d27ced51228d114be Mon Sep 17 00:00:00 2001 From: esok Date: Thu, 8 Jan 2026 00:56:56 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EC=9E=90=EA=B8=B0=20=EC=9E=90?= =?UTF-8?q?=EC=8B=A0=EC=9D=98=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=8A=94=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appserver/apps/account/constants.py | 2 + appserver/apps/account/deps.py | 47 ++++++++++++ appserver/apps/account/endpoints.py | 106 +++++++++++++++++++++------ appserver/apps/account/exceptions.py | 20 ++++- appserver/apps/account/schemas.py | 11 ++- tests/apps/account/test_me_api.py | 41 +++++++++++ tests/conftest.py | 25 ++++++- 7 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 appserver/apps/account/constants.py create mode 100644 appserver/apps/account/deps.py create mode 100644 tests/apps/account/test_me_api.py diff --git a/appserver/apps/account/constants.py b/appserver/apps/account/constants.py new file mode 100644 index 0000000..9920459 --- /dev/null +++ b/appserver/apps/account/constants.py @@ -0,0 +1,2 @@ +# 쿠키 이름을 하드 코딩하지 않고 관리하기 +AUTH_TOKEN_COOKIE_NAME = "auth_token" \ No newline at end of file diff --git a/appserver/apps/account/deps.py b/appserver/apps/account/deps.py new file mode 100644 index 0000000..ca129e8 --- /dev/null +++ b/appserver/apps/account/deps.py @@ -0,0 +1,47 @@ +from datetime import datetime, timezone, timedelta +from typing import Annotated + +from fastapi import Cookie, Depends +from sqlmodel import select + +from appserver.db import DbSessionDep +from .exceptions import ( + ExpiredTokenError, + InvalidTokenError, + UserNotFoundError, +) +from .models import User +from .utils import ACCESS_TOKEN_EXPIRE_MINUTES, decode_token + +async def get_current_user( + auth_token: Annotated[str, Cookie()], + db_session: DbSessionDep, +): + # 쿠키에 토큰이 없으면 인증 실패 + if auth_token is None: + raise InvalidTokenError() + + # 토큰 디코딩 (실패 시 인증 오류로 변환) + try: + decoded = decode_token(auth_token) + except Exception as e: + raise InvalidTokenError() from e + + # 토큰의 만료 여부 확인 + expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) + now = datetime.now(timezone.utc) + if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: + raise ExpiredTokenError() + + # 토큰의 subject(username)으로 사용자 조회 + stmt = select(User).where(User.username == decoded["sub"]) + result = await db_session.execute(stmt) + user = result.scalar_one_or_none() + if user is None: + raise UserNotFoundError() + + return user + +# 의존성 주입용 타입 별칭 +CurrentUserDep = Annotated[User, Depends(get_current_user)] + diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 0fa363a..6e43a2a 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -1,24 +1,31 @@ from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, HTTPException, status from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError +from sqlmodel import func, select + +from appserver.db import DbSessionDep +from .constants import AUTH_TOKEN_COOKIE_NAME +from .deps import CurrentUserDep +from .exceptions import ( + DuplicatedEmailError, + DuplicatedUsernameError, + PasswordMismatchError, + UserNotFoundError, +) +from .models import User +from .schemas import ( + LoginPayload, + SignupPayload, + UserDetailOut, + UserOut, +) from .utils import ( - verify_password, + ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token, - ACCESS_TOKEN_EXPIRE_MINUTES + verify_password, ) -from fastapi import APIRouter, HTTPException, status -from sqlmodel import select, SQLModel -from appserver.db import DbSessionDep -# .models에서 User 모델을 가져옴 (현재 코드에서 직접 쓰이지 않지만 확장을 위한 존재) -from .models import User -# 데이터베이스 중복 확인을 위한 select, func -from sqlmodel import select, func -# 중복 사용자명 예외 -from .exceptions import DuplicatedUsernameError, DuplicatedEmailError -from sqlalchemy.exc import IntegrityError -from .schemas import SignupPayload, UserOut -from .exceptions import PasswordMismatchError, UserNotFoundError -from .schemas import SignupPayload, UserOut, LoginPayload -from .utils import verify_password # /account 경로로 시작하는 API 그룹 생성 router = APIRouter(prefix="/account") @@ -105,43 +112,96 @@ async def signup(payload: SignupPayload, session: DbSessionDep) -> User: async def login(payload: LoginPayload, session: DbSessionDep) -> JSONResponse: """ 로그인 요청을 수신하고, 자격이 확인되면 토큰과 쿠키를 반환합니다. + + 1. 사용자 조회 + 2. 비밀번호 검증 + 3. JWT 액세스 토큰 생성 (신분증 발급) + 4. 쿠키에 토큰 설정 (신분증을 지갑에 넣기) """ + # ========== 1. 사용자 조회 ========== stmt = select(User).where(User.username == payload.username) result = await session.execute(stmt) + # scalar_one_or_none(): + # - 결과가 1개면 해당 객체 반환 + # - 없으면 None 반환 + # - 2개 이상이면 에러 발생 (Unique 조건이 있어서 2개일 수는 없음) user = result.scalar_one_or_none() + if user is None: raise UserNotFoundError() - # 입력한 비밀번호를 저장된 해시와 비교해서 유효성 검사 + # ========== 2. 비밀번호 검증 ========== + # 입력한 평문 비밀번호(payload.password)와 DB에 저장된 해시(user.hashed_password) 비교 + # verify_password 내부에서 Argon2/Bcrypt 등을 사용하여 검증함 is_valid = verify_password(payload.password, user.hashed_password) if not is_valid: raise PasswordMismatchError() + # ========== 3. 토큰 생성 (신분증 발급) ========== access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + # payload(내용물)에는 식별 가능한 최소한의 정보를 담습니다. + # 비밀번호 같은 민감 정보는 절대 담으면 안 됩니다. access_token = create_access_token( data={ - "sub": user.username, + "sub": user.username, # sub (Subject): 토큰의 주인 (보통 ID 사용) "displayname": user.display_name, "is_host": user.is_host, }, expires_delta=access_token_expires, ) + + # 응답 본문(Body)에 담을 데이터 구성 response_data = { "access_token": access_token, "token_type": "bearer", "user": user.model_dump(mode="json", exclude={"hashed_password", "email"}) } - # 토큰 만료 시간 기준으로 쿠키도 설정하기 + # ========== 4. 쿠키 설정 및 응답 (지갑에 넣기) ========== + # JSONResponse 객체를 직접 생성해야 쿠키(Header)를 조작할 수 있습니다. + res = JSONResponse(response_data, status_code=status.HTTP_200_OK) + + # 현재 시간 (쿠키 만료 계산용) now = datetime.now(timezone.utc) - - res = JSONResponse(response_data) + + # set_cookie: 브라우저에게 "이 데이터를 쿠키 저장소에 저장해!"라고 명령 res.set_cookie( - key="auth_token", - value=access_token, + key=AUTH_TOKEN_COOKIE_NAME, # 쿠키 이름 (예: "access_token") + value=access_token, # 쿠키 값 (JWT 문자열) + + # 만료 시간 설정 (이 시간이 지나면 브라우저가 알아서 쿠키를 삭제함) expires=now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + + # [보안 중요] httponly=True: + # 자바스크립트(document.cookie)로 이 쿠키에 접근할 수 없게 막음. + # XSS(교차 사이트 스크립팅) 공격 시 토큰 탈취를 방지하는 핵심 옵션. httponly=True, + + # [보안 중요] secure=True: + # HTTPS(암호화된 연결)인 경우에만 쿠키를 서버로 전송함. + # 네트워크 스니핑(패킷 가로채기)으로부터 토큰을 보호함. (로컬 개발에선 http여도 동작하게 설정 필요할 수 있음) secure=True, + + # [보안 중요] samesite="strict": + # 다른 사이트에서 우리 사이트로 요청을 보낼 때 쿠키를 전송하지 않음. + # CSRF(사이트 간 요청 위조) 공격을 방지함. samesite="strict" ) return res + +@router.get("/@me", response_model=UserDetailOut) +async def me(user: CurrentUserDep) -> User: + """ + 현재 로그인한 사용자의 정보를 반환합니다. + + Args: + user (CurrentUserDep): + FastAPI의 Dependency Injection 시스템이 동작합니다. + 1. 요청의 쿠키에서 토큰을 꺼냅니다. + 2. 토큰을 검증하고 디코딩하여 username을 얻습니다. + 3. DB에서 해당 username을 조회하여 User 객체를 만들어 여기에 주입해줍니다. + (이 모든 과정은 deps.py의 get_current_user 함수에서 처리됩니다) + """ + return user + diff --git a/appserver/apps/account/exceptions.py b/appserver/apps/account/exceptions.py index 0511107..12bf1da 100644 --- a/appserver/apps/account/exceptions.py +++ b/appserver/apps/account/exceptions.py @@ -26,4 +26,22 @@ def __init__(self): super().__init__( status_code=status.HTTP_401_UNAUTHORIZED, detail="Password mismatch", - ) \ No newline at end of file + ) + +class InvalidTokenError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 인증 토큰입니다", + headers={"WWW-Authenticate": "Bearer"} + ) + +class ExpiredTokenError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증 토큰이 만료되었습니다", + headers={"WWW-Authenticate": "Bearer"}, + ) + + \ No newline at end of file diff --git a/appserver/apps/account/schemas.py b/appserver/apps/account/schemas.py index 8145bf0..614665f 100644 --- a/appserver/apps/account/schemas.py +++ b/appserver/apps/account/schemas.py @@ -1,7 +1,7 @@ import random import string from typing_extensions import Self # Python 3.10 호환성 -from pydantic import model_validator, EmailStr +from pydantic import model_validator, EmailStr, AwareDatetime from sqlmodel import SQLModel, Field # ============ 회원가입 입력 스키마 ============ @@ -53,4 +53,11 @@ class UserOut(SQLModel): class LoginPayload(SQLModel): """로그인 API에서 받는 페이로드""" username: str = Field(min_length=4, max_length=40) - password: str = Field(min_length=8, max_length=128) \ No newline at end of file + password: str = Field(min_length=8, max_length=128) + +class UserDetailOut(UserOut): + email: EmailStr + created_at: AwareDatetime + updated_at: AwareDatetime + + \ No newline at end of file diff --git a/tests/apps/account/test_me_api.py b/tests/apps/account/test_me_api.py new file mode 100644 index 0000000..a1ecd31 --- /dev/null +++ b/tests/apps/account/test_me_api.py @@ -0,0 +1,41 @@ +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.models import User +from appserver.apps.account.utils import decode_token, create_access_token +from datetime import datetime, timedelta, timezone + +def test_내_정보_조회(client_with_auth: TestClient, host_user: User): + response = client_with_auth.get("/account/@me") + data = response.json() + assert response.status_code == status.HTTP_200_OK + + response_keys = frozenset(data.keys()) + expected_keys = frozenset(["username", "display_name", "is_host", "email", "created_at", "updated_at"]) + assert response_keys == expected_keys + +def test_토큰이_없는_경우_의심스런_접근_오류를_일으킨다(client: TestClient): + response = client.get("/account/@me") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + +def test_유효하지_않은_토큰인_경우_인증_오류를_일으킨다(client_with_auth: TestClient): + client_with_auth.cookies["auth_token"] = "invalid_token" + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_만료된_토큰으로_내_정보_조회(client_with_auth: TestClient): + token = client_with_auth.cookies.get("auth_token", domain="", path="/") + decoded = decode_token(token) + jwt = create_access_token(decoded, timedelta(hours=-1)) + client_with_auth.cookies["auth_token"] = jwt + + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_유저가_존재하지_않은_경우_내_정보_조회(client_with_auth: TestClient): + token = client_with_auth.cookies.get("auth_token", domain="", path="/") + decoded = decode_token(token) + decoded["sub"] = "invalid_user_id" + jwt = create_access_token(decoded) + client_with_auth.cookies["auth_token"] = jwt + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 46c7cd9..2f54e87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,8 @@ from appserver.apps.account import models as account_models from appserver.apps.calendar import models as calendar_models # ensure Calendar model is registered from sqlmodel import SQLModel -from fastapi import FastAPI +from fastapi import FastAPI, status +from appserver.apps.account.schemas import LoginPayload from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine from appserver.db import create_engine, create_session, use_session from appserver.app import include_routers @@ -88,3 +89,25 @@ async def host_user(db_session: AsyncSession): await db_session.commit() await db_session.flush(user) return user + +@pytest.fixture() +def client_with_auth(fastapi_app: FastAPI, host_user: account_models.User): + """ + host_user로 로그인한 상태의 TestClient를 반환한다. (auth_token 쿠키 포함) + """ + payload = LoginPayload( + username=host_user.username, + password="testtest", + ) + + with TestClient(fastapi_app) as client: + response = client.post("/account/login", json=payload.model_dump()) + assert response.status_code == status.HTTP_200_OK + + auth_token = response.cookies.get("auth_token") + assert auth_token is not None + + client.cookies["auth_token"] = auth_token + yield client + + \ No newline at end of file From b1a1a42bbdbb529ce54767151f72272b98109ec2 Mon Sep 17 00:00:00 2001 From: esok Date: Thu, 8 Jan 2026 03:10:48 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=B3=80=EA=B2=BD=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 11 ++--- appserver/apps/account/endpoints.py | 16 ++++++- appserver/apps/account/schemas.py | 28 ++++++++++- tests/apps/account/test_update_user_api.py | 55 ++++++++++++++++++++++ 4 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 tests/apps/account/test_update_user_api.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e115e0..c1de8c6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ -{ +{ "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", "python.testing.cwd": "${workspaceFolder}", "python.testing.unittestEnabled": false, @@ -8,16 +8,13 @@ "python.testing.pytestArgs": [ "." ], - "python.testing.autoTestDiscoverOnSaveEnabled": true, + "python.testing.autoTestDiscoverOnSaveEnabled": false, "python.analysis.autoImportCompletions": true, "python.analysis.indexing": true, "python.analysis.completeFunctionParens": true, "python.analysis.autoSearchPaths": true, "python.analysis.useLibraryCodeForTypes": true, "python.analysis.typeCheckingMode": "off", - "python.analysis.include": [ - "./appserver" - ], "python.analysis.extraPaths": [ "./appserver" ], @@ -25,5 +22,5 @@ "./appserver" ], "python.terminal.activateEnvironment": true, - "python-envs.pythonProjects": [], -} \ No newline at end of file + "python-envs.pythonProjects": [] +} diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 6e43a2a..05ae08b 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError -from sqlmodel import func, select +from sqlmodel import func, select, update from appserver.db import DbSessionDep from .constants import AUTH_TOKEN_COOKIE_NAME @@ -26,6 +26,8 @@ create_access_token, verify_password, ) +from .schemas import UpdateUserPayload + # /account 경로로 시작하는 API 그룹 생성 router = APIRouter(prefix="/account") @@ -205,3 +207,15 @@ async def me(user: CurrentUserDep) -> User: """ return user +@router.patch("/@me", response_model=UserDetailOut) +async def update_user( + user: CurrentUserDep, + payload: UpdateUserPayload, + session: DbSessionDep +) -> User: + updated_data = payload.model_dump(exclude_unset=True, exclude={"password", "password_again"}) + stmt = update(User).where(User.username == user.username).values(**updated_data) + await session.execute(stmt) + await session.commit() + return user + diff --git a/appserver/apps/account/schemas.py b/appserver/apps/account/schemas.py index 614665f..0b046cd 100644 --- a/appserver/apps/account/schemas.py +++ b/appserver/apps/account/schemas.py @@ -1,8 +1,9 @@ import random import string from typing_extensions import Self # Python 3.10 호환성 -from pydantic import model_validator, EmailStr, AwareDatetime +from pydantic import model_validator, EmailStr, AwareDatetime, computed_field from sqlmodel import SQLModel, Field +from .utils import hash_password # ============ 회원가입 입력 스키마 ============ # 클라이언트로부터 받는 회원가입 데이터의 형식을 정의 @@ -60,4 +61,27 @@ class UserDetailOut(UserOut): created_at: AwareDatetime updated_at: AwareDatetime - \ No newline at end of file +class UpdateUserPayload(SQLModel): + display_name: str | None = Field(default=None, min_length=4, max_length=40) + email: EmailStr | None = Field(default=None, unique=True, max_length=128) + password: str | None = Field(default=None, min_length=8, max_length=128) + password_again: str | None = Field(default=None, min_length=8, max_length=128) + + @model_validator(mode="after") + def check_all_fields_are_none(self) -> Self: + if not self.model_dump(exclude_none=True): + raise ValueError("최소 하나의 필드는 반드시 제공되어야 합니다.") + return self + + @model_validator(mode="after") + def verify_password(self) -> Self: + if self.password != self.password_again: + raise ValueError("비밀번호가 일치하지 않습니다.") + return self + + @computed_field + @property + def hashed_password(self) -> str | None: + if self.password: + return hash_password(self.password) + return None \ No newline at end of file diff --git a/tests/apps/account/test_update_user_api.py b/tests/apps/account/test_update_user_api.py new file mode 100644 index 0000000..27bdb70 --- /dev/null +++ b/tests/apps/account/test_update_user_api.py @@ -0,0 +1,55 @@ +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.acocunt.models import User +from sqlalchemy.ext.asyncio import AsyncSession + +UPDATABLE_FIELDS = frozenset(["display_name", "email"]) + +@pytest.mark.parametrize("payload", [ + {"display_name": "푸딩캠프"}, + {"email": "test@example.com"}, + {"display_name": "푸딩캠프", "email": "test@example.com"}, +]) +async def test_사용자가_변경하는_항목만_변경되고_나머지는_기존_값을_유지한다( + client_with_auth: TestClient, + payload: dict, + host_user: User +): + # 현재 사용자 정보를 보관한다. + before_data = host_user.model_dump() + response = client_with_auth.patch("/account/@me", json=payload) + assert response.status_code == status.HTTP_200_OK + data = response.json() + + # 변경된 항목은 변경된 값으로 변경되어야 한다. + for key, value in payload.items(): + assert data[key] == value + + # 변경되지 않은 항목은 기존 값을 유지한다. + for key in UPDATABLE_FIELDS - frozenset(payload.keys()): + assert data[key] == before_data[key] + +async def test_최소_하나_이상_항목을_변경해야_하며_그렇지_않으면_오류를_일으킨다( + client_with_auth: TestClient, +): + response = client_with_auth.patch("/account/@me", json={}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + +async def test_비밀번호_변경_시_해싱_처리한_비밀번호가_저장되어야_한다( + client_with_auth: TestClient, + host_user: User, + db_session: AsyncSession, +): + before_password = host_user.hashed_password + payload = { + "password": "new_password", + "password_again": "new_password", + } + + response = client_with_auth.patch("/account/@me", json=payload) + assert response.status_code == status.HTTP_200_OK + + await db_session.refresh(host_user) + assert host_user.hashed_password != before_password + \ No newline at end of file From 2c5cc02df228a8fd35c4b9bc42644c54e7633ddc Mon Sep 17 00:00:00 2001 From: esok Date: Thu, 8 Jan 2026 03:43:49 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appserver/apps/account/endpoints.py | 14 +++++++++++++- appserver/apps/account/utils.py | 3 +++ pyproject.toml | 4 ++-- tests/apps/account/test_logout_api.py | 12 ++++++++++++ tests/apps/account/test_unregister_api.py | 21 +++++++++++++++++++++ tests/apps/account/test_update_user_api.py | 4 ++-- 6 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 tests/apps/account/test_logout_api.py create mode 100644 tests/apps/account/test_unregister_api.py diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 05ae08b..0283970 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError -from sqlmodel import func, select, update +from sqlmodel import func, select, update, delete from appserver.db import DbSessionDep from .constants import AUTH_TOKEN_COOKIE_NAME @@ -219,3 +219,15 @@ async def update_user( await session.commit() return user +@router.delete("/logout", status_code=status.HTTP_200_OK) +async def logout(user: CurrentUserDep) -> JSONResponse: + res = JSONResponse({}) + res.delete_cookie(AUTH_TOKEN_COOKIE_NAME) + return res + +@router.delete("/unregister", status_code=status.HTTP_204_NO_CONTENT) +async def unregister(user: CurrentUserDep, session:DbSessionDep) -> None: + stmt = delete(User).where(User.username == user.username) + await session.execute(stmt) + await session.commit() + return None diff --git a/appserver/apps/account/utils.py b/appserver/apps/account/utils.py index 2affe37..275c843 100644 --- a/appserver/apps/account/utils.py +++ b/appserver/apps/account/utils.py @@ -52,6 +52,9 @@ def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt +def decode_token(token: str) -> dict[str, Any]: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + if __name__ == "__main__": password = "dhrdmstn" hashed_password = hash_password(password) diff --git a/pyproject.toml b/pyproject.toml index 91f446d..07b1f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,10 +34,10 @@ env = [] addopts = " --strict-markers --tb=short --asyncio-mode=auto -p no:warnings --doctest-modules" python_files = ["tests.py", "test_*.py"] rootdir = "./" -testpaths = ["./tests", "./appserver"] +testpaths = ["./tests"] pythonpath = ["./"] filterwarnings = ["error", "ignore::DeprecationWarning:etcd3.*:"] log_cli = true log_cli_level = "WARNING" log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" -log_cli_date_format = "%Y-%m-%d %H:%M:%S" \ No newline at end of file +log_cli_date_format = "%Y-%m-%d %H:%M:%S" diff --git a/tests/apps/account/test_logout_api.py b/tests/apps/account/test_logout_api.py new file mode 100644 index 0000000..4cb71c7 --- /dev/null +++ b/tests/apps/account/test_logout_api.py @@ -0,0 +1,12 @@ +from fastapi.testclient import TestClient +from fastapi import status +from appserver.apps.account.constants import AUTH_TOKEN_COOKIE_NAME +from appserver.apps.account.models import User + +async def test_로그아웃_시_인증_토큰이_삭제되어야_한다( + client_with_auth: TestClient, +): + response = client_with_auth.delete("/account/logout") + assert response.status_code == status.HTTP_200_OK + assert response.cookies.get(AUTH_TOKEN_COOKIE_NAME) is None + diff --git a/tests/apps/account/test_unregister_api.py b/tests/apps/account/test_unregister_api.py new file mode 100644 index 0000000..386a035 --- /dev/null +++ b/tests/apps/account/test_unregister_api.py @@ -0,0 +1,21 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.testclient import TestClient +from fastapi import status +from appserver.apps.account.models import User + +async def test_회원탈퇴_시_유저가_삭제되어야_한다( + client_with_auth: TestClient, + host_user: User, + db_session: AsyncSession, +): + user_id = host_user.id + + assert await db_session.get(User, user_id) is not None + response = client_with_auth.delete("/account/unregister") + + assert response.status_code == status.HTTP_204_NO_CONTENT + # Clear identity map so we don't read cached instance from another session. + db_session.expire_all() + assert await db_session.get(User, user_id) is None + + diff --git a/tests/apps/account/test_update_user_api.py b/tests/apps/account/test_update_user_api.py index 27bdb70..7ed6ffe 100644 --- a/tests/apps/account/test_update_user_api.py +++ b/tests/apps/account/test_update_user_api.py @@ -1,7 +1,7 @@ import pytest from fastapi import status from fastapi.testclient import TestClient -from appserver.apps.acocunt.models import User +from appserver.apps.account.models import User from sqlalchemy.ext.asyncio import AsyncSession UPDATABLE_FIELDS = frozenset(["display_name", "email"]) @@ -52,4 +52,4 @@ async def test_비밀번호_변경_시_해싱_처리한_비밀번호가_저장 await db_session.refresh(host_user) assert host_user.hashed_password != before_password - \ No newline at end of file + From d217ed70120fccaa7161ad09af8f92d1f65fd9fc Mon Sep 17 00:00:00 2001 From: esok Date: Mon, 19 Jan 2026 12:41:54 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EC=84=A0=ED=83=9D=EC=A0=81=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Calendar 엔드포인트 및 스키마 추가 - 선택적 사용자 인증(CurrentUserOptionalDep) 구현 - 호스트/게스트 권한별 응답 스키마 분리 (CalendarOut/CalendarDetailOut) - pytest parametrize를 활용한 테스트 리팩토링 - 중복 테스트 코드 제거 및 fixture 추가 --- appserver/apps/account/deps.py | 55 +++- appserver/apps/account/models.py | 1 + appserver/apps/calendar/endpoints.py | 36 +++ appserver/apps/calendar/schemas.py | 0 study_plan.ipynb | 417 +++++++++++++++++++++++++++ tests/apps/calendar/test_calendar.py | 84 ++++++ tests/conftest.py | 30 +- 7 files changed, 609 insertions(+), 14 deletions(-) create mode 100644 appserver/apps/calendar/endpoints.py create mode 100644 appserver/apps/calendar/schemas.py create mode 100644 study_plan.ipynb create mode 100644 tests/apps/calendar/test_calendar.py diff --git a/appserver/apps/account/deps.py b/appserver/apps/account/deps.py index ca129e8..5bfd770 100644 --- a/appserver/apps/account/deps.py +++ b/appserver/apps/account/deps.py @@ -12,31 +12,50 @@ ) from .models import User from .utils import ACCESS_TOKEN_EXPIRE_MINUTES, decode_token +from sqlalchemy.ext.asyncio import AsyncSession -async def get_current_user( - auth_token: Annotated[str, Cookie()], - db_session: DbSessionDep, -): - # 쿠키에 토큰이 없으면 인증 실패 - if auth_token is None: - raise InvalidTokenError() - - # 토큰 디코딩 (실패 시 인증 오류로 변환) +async def get_user(auth_token: str | None, db_session: AsnycSession) -> User | None: + if not auth_token: + return None try: decoded = decode_token(auth_token) except Exception as e: raise InvalidTokenError() from e - # 토큰의 만료 여부 확인 expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) now = datetime.now(timezone.utc) if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: - raise ExpiredTokenError() + raise ExpiredTokenError() from e - # 토큰의 subject(username)으로 사용자 조회 stmt = select(User).where(User.username == decoded["sub"]) result = await db_session.execute(stmt) - user = result.scalar_one_or_none() + + return result.scalar_one_or_none() + +async def get_current_user( + auth_token: Annotated[str, Cookie()], + db_session: DbSessionDep, +): + # # 쿠키에 토큰이 없으면 인증 실패 + # if auth_token is None: + # raise InvalidTokenError() + + # # 토큰 디코딩 (실패 시 인증 오류로 변환) + # try: + # decoded = decode_token(auth_token) + # except Exception as e: + # raise InvalidTokenError() from e + + # # 토큰의 만료 여부 확인 + # expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) + # now = datetime.now(timezone.utc) + # if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: + # raise ExpiredTokenError() + + # # 토큰의 subject(username)으로 사용자 조회 + # stmt = select(User).where(User.username == decoded["sub"]) + # result = await db_session.execute(stmt) + # user = result.scalar_one_or_none() if user is None: raise UserNotFoundError() @@ -45,3 +64,13 @@ async def get_current_user( # 의존성 주입용 타입 별칭 CurrentUserDep = Annotated[User, Depends(get_current_user)] +async def get_current_user_optional( + db_session: DbSessionDep, + auth_token: Annotated[str | None, Cookie()] = None, +): + user = await get_user(auth_token, db_session) + return user + +CurrentUserOptionalDep = Annotated[User | None, Depends(get_current_user_optional)] + + diff --git a/appserver/apps/account/models.py b/appserver/apps/account/models.py index 7af5438..027fad5 100644 --- a/appserver/apps/account/models.py +++ b/appserver/apps/account/models.py @@ -8,6 +8,7 @@ import string from pydantic import model_validator + if TYPE_CHECKING: from appserver.apps.calendar.models import Calendar, Booking diff --git a/appserver/apps/calendar/endpoints.py b/appserver/apps/calendar/endpoints.py new file mode 100644 index 0000000..ecab8fb --- /dev/null +++ b/appserver/apps/calendar/endpoints.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, status +from sqlmodel import select +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from appserver.db import DbSessionDep +from appserver.apps.account.deps import CurrentUserOptionalDep +from .schemas import CalendarDetailOut, CalendarOut + +async def host_calendar_detail( + host_username: str, + user: CurrentUserOptionalDep, + session: DbSessionDep +) -> CalendarOut | CalendarDetailOut: + """ + 데이터베이스에서 특정 사용자 정보를 조회합니다. + + Args: + host_username (str): 조회할 사용자의 username + user (CurrentUserOptionalDep): 현재 로그인한 사용자 정보 + session (DbSessionDep): 데이터베이스 세션 + + Returns: + CalendarOut | CalendarDetailOut: 조회된 사용자 정보 + """ + stmt = select(User).where(User.username == host_username) + result = await session.execute(stmt) + host = result.scalar_one_or_none() + + stmt = select(Calendar).where(Calendar.host_id == host.id) + result = await session.execute(stmt) + calendar = result.scalar_one_or_none() + if user is not None and user.id == host_id: + return CalendarDetailOut.model_validate(calendar) + + return CalendarOut.model_validate(calendar) + \ No newline at end of file diff --git a/appserver/apps/calendar/schemas.py b/appserver/apps/calendar/schemas.py new file mode 100644 index 0000000..e69de29 diff --git a/study_plan.ipynb b/study_plan.ipynb new file mode 100644 index 0000000..a539622 --- /dev/null +++ b/study_plan.ipynb @@ -0,0 +1,417 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📘 FastAPI 완전 정복 노트\n", + "\n", + "이 노트북은 여러분이 작성한 모든 코드를 직접 실행해보고 검증할 수 있는 통합 실습 환경입니다.\n", + "DB 생성부터 API 호출, 보안 검증, 그리고 마이그레이션까지 순서대로 진행하며 전체 흐름을 익혀봅시다!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 🛠 환경 설정 (Imports & Setup)\n", + "\n", + "프로젝트의 모든 모듈을 불러올 수 있도록 경로를 설정하고 필요한 라이브러리를 임포트합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import os\n", + "from typing import AsyncGenerator\n", + "\n", + "# 현재 프로젝트 루트를 파이썬 경로에 추가\n", + "current_dir = os.getcwd()\n", + "if current_dir not in sys.path:\n", + " sys.path.append(current_dir)\n", + "\n", + "import asyncio\n", + "import nest_asyncio\n", + "nest_asyncio.apply()\n", + "\n", + "import pytest\n", + "from httpx import AsyncClient, ASGITransport\n", + "from sqlmodel import SQLModel, select\n", + "from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession\n", + "from sqlalchemy.orm import sessionmaker\n", + "\n", + "# FastAPI 앱 및 모델 임포트\n", + "from fastapi import FastAPI\n", + "from appserver.app import include_routers\n", + "from appserver.db import use_session\n", + "from appserver.apps.account.models import User\n", + "from appserver.apps.account.schemas import UserCreatePayload, LoginPayload\n", + "from appserver.apps.account.utils import hash_password, verify_password\n", + "from dataclasses import dataclass\n", + "\n", + "print(\"✅ 모든 라이브러리 임포트 완료!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 🗄 데이터베이스 구축 (In-Memory DB)\n", + "\n", + "실제 서버를 띄우지 않고, 노트북 안에서만 사용할 \"가짜 DB(메모리 DB)\"를 만듭니다.\n", + "이 단계가 성공해야 데이터를 저장하고 불러올 수 있습니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 비동기 엔진 생성 (메모리 DB)\n", + "test_engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n", + "\n", + "async def init_db():\n", + " async with test_engine.begin() as conn:\n", + " # 모든 테이블 생성 (User 테이블 등)\n", + " await conn.run_sync(SQLModel.metadata.create_all)\n", + " print(\"✅ 데이터베이스 테이블 생성 완료!\")\n", + "\n", + "# 주피터는 이미 비동기 루프가 돌고 있으므로 await로 바로 실행\n", + "await init_db()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 🛡 로직 단위 테스트: 비밀번호 해싱\n", + "\n", + "`appserver/apps/account/utils.py`의 핵심 보안 기능을 먼저 검증합니다.\n", + "내가 입력한 비밀번호가 DB에 어떻게 저장되는지 눈으로 확인하세요." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "raw_pw = \"ilovepython\"\n", + "hashed_pw = hash_password(raw_pw)\n", + "\n", + "print(f\"🔑 원본 비밀번호: {raw_pw}\")\n", + "print(f\"🔒 해시된 비밀번호: {hashed_pw}\")\n", + "\n", + "# 검증 테스트\n", + "assert verify_password(raw_pw, hashed_pw) == True, \"비밀번호 검증 실패!\"\n", + "assert verify_password(\"wrongpw\", hashed_pw) == False, \"틀린 비밀번호가 통과됨!\"\n", + "print(\"✅ 비밀번호 보안 로직 정상 작동!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 🌐 가상 API 클라이언트 생성\n", + "\n", + "브라우저 없이 코드로 서버와 통신할 수 있는 `AsyncClient`를 만듭니다.\n", + "이 클라이언트가 여러분 대신 `/account/signup`, `/account/login` 등으로 요청을 보낼 겁니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. FastAPI 앱 인스턴스 생성\n", + "app = FastAPI()\n", + "include_routers(app)\n", + "\n", + "# 2. DB 의존성 오버라이드 (가짜 DB 사용하도록 연결)\n", + "async def override_use_session() -> AsyncGenerator[AsyncSession, None]:\n", + " async_session = sessionmaker(\n", + " test_engine, class_=AsyncSession, expire_on_commit=False\n", + " )\n", + " async with async_session() as session:\n", + " yield session\n", + "\n", + "app.dependency_overrides[use_session] = override_use_session\n", + "\n", + "# 3. 클라이언트 생성\n", + "client = AsyncClient(transport=ASGITransport(app=app), base_url=\"http://test\")\n", + "print(\"✅ 가상 API 클라이언트 준비 완료!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 🚀 실전 시나리오 1: 회원가입 (Sign Up)\n", + "\n", + "유저가 회원가입 폼을 작성해서 제출하는 상황을 시뮬레이션합니다.\n", + "직접 JSON 데이터를 만들어서 POST 요청을 보내봅니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "signup_data = {\n", + " \"username\": \"fastapi_master\",\n", + " \"password\": \"code1234\",\n", + " \"password_again\": \"code1234\",\n", + " \"email\": \"master@example.com\",\n", + " \"display_name\": \"파이썬고수\"\n", + "}\n", + "\n", + "response = await client.post(\"/account/signup\", json=signup_data)\n", + "\n", + "print(f\"📡 응답 상태 코드: {response.status_code}\")\n", + "print(f\"📄 응답 데이터: {response.json()}\")\n", + "\n", + "if response.status_code == 201:\n", + " print(\"✅ 회원가입 성공!\")\n", + "else:\n", + " print(\"❌ 회원가입 실패 (로그 확인 필요)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 🔍 DB 확인: 진짜 저장되었나요?\n", + "\n", + "API는 성공했다고 하지만, 진짜 DB에 데이터가 들어갔는지 의심스러우시죠?\n", + "직접 SELECT 쿼리를 날려서 확인해봅시다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)\n", + "\n", + "async with async_session() as session:\n", + " # SELECT * FROM user WHERE username = 'fastapi_master'\n", + " stmt = select(User).where(User.username == \"fastapi_master\")\n", + " result = await session.execute(stmt)\n", + " user = result.scalar_one_or_none()\n", + "\n", + " if user:\n", + " print(f\"✅ DB 조회 성공!\")\n", + " print(f\"ID: {user.id}\")\n", + " print(f\"Username: {user.username}\")\n", + " print(f\"Email: {user.email}\")\n", + " print(f\"Hashed Password: {user.hashed_password} (안전하게 암호화됨)\")\n", + " else:\n", + " print(\"❌ DB에 유저가 없습니다!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 🚀 실전 시나리오 2: 로그인 (Login)\n", + "\n", + "이제 회원가입한 아이디로 로그인을 시도합니다.\n", + "성공하면 **Access Token(신분증)**을 쿠키나 바디로 받아야 합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "login_data = {\n", + " \"username\": \"fastapi_master\",\n", + " \"password\": \"code1234\"\n", + "}\n", + "\n", + "response = await client.post(\"/account/login\", json=login_data)\n", + "\n", + "print(f\"📡 응답 상태 코드: {response.status_code}\")\n", + "print(f\"📄 응답 바디: {response.json()}\")\n", + "\n", + "# 쿠키 확인\n", + "cookies = response.cookies\n", + "auth_token = cookies.get(\"auth_token\")\n", + "\n", + "if auth_token:\n", + " print(f\"\\n🍪 발급된 쿠키(auth_token): {auth_token[:20]}... (생략)\")\n", + " print(\"✅ 로그인 및 토큰 발급 성공!\")\n", + "else:\n", + " print(\"❌ 쿠키가 없습니다. 로그인 로직을 점검하세요.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 🚀 실전 시나리오 3: 내 정보 수정 (Update)\n", + "\n", + "로그인 상태(`client`가 쿠기를 기억함)에서 닉네임을 변경해봅니다.\n", + "Dependency Injection(`get_current_user`)이 토큰을 어떻게 해석해서 유저를 찾아내는지 체험합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. 현재 정보 확인 (@me)\n", + "me_res = await client.get(\"/account/@me\")\n", + "print(f\"변경 전 닉네임: {me_res.json()['display_name']}\")\n", + "\n", + "# 2. 정보 수정 요청 (PATCH)\n", + "update_data = {\"display_name\": \"AI마스터\"}\n", + "patch_res = await client.patch(\"/account/@me\", json=update_data)\n", + "\n", + "# 3. 변경 후 정보 확인\n", + "me_res_after = await client.get(\"/account/@me\")\n", + "print(f\"변경 후 닉네임: {me_res_after.json()['display_name']}\")\n", + "\n", + "assert me_res_after.json()['display_name'] == \"AI마스터\"\n", + "print(\"✅ 내 정보 수정 성공!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 🧹 테스트 리소스 정리\n", + "사용했던 자원을 정리합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await client.aclose()\n", + "await test_engine.dispose()\n", + "print(\"✅ 테스트 종료 및 리소스 해제 완료\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 🏛 데이터 모델링 & 마이그레이션 (Alembic)\n", + "\n", + "지금까지는 코드가 꺼지면 데이터가 사라지는 '메모리 DB'를 사용했습니다.\n", + "이제는 **파일로 남는 진짜 DB(local.db)**를 사용하고, 테이블 구조를 변경하는 실습을 해보겠습니다.\n", + "\n", + "### 실습 목표: `Booking` 테이블에 `memo` 필드 추가하기" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from sqlmodel import Field, Text\n", + "\n", + "model_path = \"appserver/apps/calendar/models.py\"\n", + "\n", + "# 1. Booking 모델 코드에 새로운 필드('memo')를 삽입하는 파이썬 스크립트\n", + "# (마치 우리가 에디터에서 코드를 수정한 것처럼 파일 내용을 바꿉니다)\n", + "with open(model_path, \"r\", encoding=\"utf-8\") as f:\n", + " content = f.read()\n", + "\n", + "if \"memo: str = Field\" not in content:\n", + " target_string = 'description: str = Field(sa_type=Text, description=\"예약 설명\")'\n", + " new_field = '\\n memo: str = Field(default=\"\", description=\"메모\")'\n", + " \n", + " new_content = content.replace(target_string, target_string + new_field)\n", + " \n", + " with open(model_path, \"w\", encoding=\"utf-8\") as f:\n", + " f.write(new_content)\n", + " print(\"✅ models.py 파일에 'memo' 필드가 추가되었습니다!\")\n", + "else:\n", + " print(\"ℹ️ 이미 'memo' 필드로 보이는 코드가 존재합니다.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 📦 마이그레이션 명령어 실행\n", + "\n", + "코드가 바뀌었으니, 이를 DB에 알리는 `Alembic` 명령어를 실행합니다.\n", + "노트북에서 `!`를 앞에 붙이면 터미널 명령어를 실행할 수 있습니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. 기존 테이블 최신화 (혹시 안 된 것이 있다면)\n", + "!alembic upgrade head\n", + "\n", + "# 2. 새로운 변경사항(리비전) 생성\n", + "# --autogenerate: 코드를 보고 바뀐 점을 자동으로 찾아라\n", + "!alembic revision --autogenerate -m \"Add memo field to booking\"\n", + "\n", + "# 3. DB에 반영 (테이블 컬럼 추가)\n", + "!alembic upgrade head" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. ✅ 체크포인트: DB 파일 확인\n", + "\n", + "이제 프로젝트 폴더에 `local.db` 파일이 생겼거나 업데이트되었을 것입니다.\n", + "이로써 여러분은 메모리 DB, 가상 클라이언트, 그리고 실제 DB 마이그레이션까지 **백엔드 개발의 전 과정**을 경험했습니다.\n", + "\n", + "수고하셨습니다! 🎉" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} \ No newline at end of file diff --git a/tests/apps/calendar/test_calendar.py b/tests/apps/calendar/test_calendar.py new file mode 100644 index 0000000..f09874a --- /dev/null +++ b/tests/apps/calendar/test_calendar.py @@ -0,0 +1,84 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.apps.calendar.models import Calendar +from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut +from appserver.apps.calendar.endpoints import host_calendar_detail + +async def test_호스트인_사용자의_username_으로_캘린더_정보를_가져온다( + host_user: User, + host_user_calendar: Calendar, + guest_user: User, + db_session: AsyncSession, +): + user = None + expected_type = CalendarOut + + result = await host_calendar_detail(host_user.username, db_session) + + assert isinstance(result, expected_type) + result_keys = frozenset(result.model_dump().keys()) + + expected_keys = frozenset(expected_type.model_fields.keys()) + assert result_keys == expected_keys + + assert result.topics == host_user_calendar.topics + assert result.description == host_user_calendar.description + + user = guest_user + expected_type = CalendarOut + # 반복 되는 코드 + result = await host_calendar_detail(host_user.username, user, db_session) + + assert isinstance(result, expected_type) + result_keys = frozenset(result.model_dump().keys()) + + expected_keys = frozenset(expected_type.model_fields.keys()) + assert result_keys == expected_keys + + assert result.topics == host_user_calendar.topics + assert result.description == host_user_calendar.description + + # 반복 되는 코드 + result = await host_calendar_detail(host_user.username, user, db_session) + + assert isinstance(result, expected_type) + result_keys = frozenset(result.model_dump().keys()) + + expected_keys = frozenset(expected_type.model_fields.keys()) + assert result_keys == expected_keys + + assert result.topics == host_user_calendar.topics + assert result.description == host_user_calendar.description + assert result.google_calendar_id == host_user_calendar.google_calendar_id + +@pytest.mark.parametrize("user_key, expected_type", [ + ("host_user", CalendarDetailOut), + ("guest_user", CalendarOut), + (None, CalendarOut), +]) +async def test_호스트인_사용자의_username으로_캘린더_정보를_가져온다( + user_key: str | None, + expected_type: type[CalendarOut | CalendarDetailOut], + host_user: User, + host_user_calendar: Calendar, + guest_user: User, + db_session: AsyncSession, +): + users = { + "host_user": host_user, + "guest_user": guest_user, + None: None, + } + user = users[user_key] + + result = await host_calendar_detail(host_user.username, user, db_session) + + assert isinstance(result, expected_type) + result_keys = frozenset(result.model_dump().keys()) + expected_keys = frozenset(expected_type.model_fields.keys()) + assert result_keys == expected_keys + + assert result.topics == host_user_calendar.topics + assert result.description == host_user_calendar.description + if expected_type is CalendarDetailOut: + assert result.google_calendar_id == host_user_calendar.google_calendar_id \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2f54e87..91a0acc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,4 +110,32 @@ def client_with_auth(fastapi_app: FastAPI, host_user: account_models.User): client.cookies["auth_token"] = auth_token yield client - \ No newline at end of file +@pytest.fixture() +async def guest_user(db_session: AsyncSession): + user = account_models.User( + username = "puddingcafe", + hashed_password=hash_password("testtest"), + email="puddingcafe@example.com", + display_name="푸딩카페", + is_host=False, + ) + db_session.add(user) + await db_session.commit() + await db_session.flush() + return user + +from appseerver.apps.calendar import models as calendar_models + +@pytest.fixture() +async def host_user_calendar(db_session: AsyncSession, host_user: account_models.User): + calendar = calendar_models.Calendar( + host_id=host_user.id, + google_calendar_id="푸딩캠프 캘린더입니다.", + topics=["푸딩캠프", "푸딩캠프2"], + google_calendar_id="1234567890", + ) + db_session.add(calendar) + await db_session.commit() + await db_session.refresh(host_user) + await db_session.flush() + return calendar \ No newline at end of file From 43a4fbcb1b3c05c62b7a9ce0335abb7e92536724 Mon Sep 17 00:00:00 2001 From: esok Date: Thu, 22 Jan 2026 15:51:50 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EB=B0=8F=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A0=81=20=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택적 사용자 인증 의존성 추가 (CurrentUserOptionalDep) - 호스트 캘린더 조회 엔드포인트 구현 - 권한별 응답 스키마 분리 (CalendarOut/CalendarDetailOut) - 캘린더 관련 예외 처리 추가 (HostNotFoundError, CalendarNotFoundError) - pytest parametrize를 활용한 테스트 리팩토링 - API 통합 테스트 추가" --- appserver/apps/__init__.py | 1 + appserver/apps/account/deps.py | 8 ++- appserver/apps/calendar/endpoints.py | 29 +++++----- appserver/apps/calendar/exceptions.py | 17 ++++++ appserver/apps/calendar/schemas.py | 13 +++++ pyproject.toml | 1 + tests/apps/calendar/test_calendar.py | 67 +++++++----------------- tests/apps/calendar/test_calendar_api.py | 53 +++++++++++++++++++ tests/conftest.py | 5 +- 9 files changed, 125 insertions(+), 69 deletions(-) create mode 100644 appserver/apps/__init__.py create mode 100644 appserver/apps/calendar/exceptions.py create mode 100644 tests/apps/calendar/test_calendar_api.py diff --git a/appserver/apps/__init__.py b/appserver/apps/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/appserver/apps/__init__.py @@ -0,0 +1 @@ + diff --git a/appserver/apps/account/deps.py b/appserver/apps/account/deps.py index 5bfd770..b01ba87 100644 --- a/appserver/apps/account/deps.py +++ b/appserver/apps/account/deps.py @@ -14,7 +14,8 @@ from .utils import ACCESS_TOKEN_EXPIRE_MINUTES, decode_token from sqlalchemy.ext.asyncio import AsyncSession -async def get_user(auth_token: str | None, db_session: AsnycSession) -> User | None: +# get_user 함수 - 토큰으로 사용자 조회하는 공통 로직 +async def get_user(auth_token: str | None, db_session: AsyncSession) -> User | None: if not auth_token: return None try: @@ -56,6 +57,7 @@ async def get_current_user( # stmt = select(User).where(User.username == decoded["sub"]) # result = await db_session.execute(stmt) # user = result.scalar_one_or_none() + user = await get_user(auth_token, db_session) if user is None: raise UserNotFoundError() @@ -64,13 +66,15 @@ async def get_current_user( # 의존성 주입용 타입 별칭 CurrentUserDep = Annotated[User, Depends(get_current_user)] +# get_current_user_optional 함수 - 선택적 인증 의존성 async def get_current_user_optional( db_session: DbSessionDep, auth_token: Annotated[str | None, Cookie()] = None, ): user = await get_user(auth_token, db_session) - return user + return user +# CurrentUserOptionalDep 타입 별칭 CurrentUserOptionalDep = Annotated[User | None, Depends(get_current_user_optional)] diff --git a/appserver/apps/calendar/endpoints.py b/appserver/apps/calendar/endpoints.py index ecab8fb..475077b 100644 --- a/appserver/apps/calendar/endpoints.py +++ b/appserver/apps/calendar/endpoints.py @@ -1,36 +1,35 @@ from fastapi import APIRouter, status from sqlmodel import select + from appserver.apps.account.models import User from appserver.apps.calendar.models import Calendar -from appserver.db import DbSessionDep from appserver.apps.account.deps import CurrentUserOptionalDep +from db import DbSessionDep + +from .exceptions import CalendarNotFoundError, HostNotFoundError from .schemas import CalendarDetailOut, CalendarOut +router = APIRouter() + + +@router.get("/calendar/{host_username}", status_code=status.HTTP_200_OK) async def host_calendar_detail( host_username: str, user: CurrentUserOptionalDep, session: DbSessionDep ) -> CalendarOut | CalendarDetailOut: - """ - 데이터베이스에서 특정 사용자 정보를 조회합니다. - - Args: - host_username (str): 조회할 사용자의 username - user (CurrentUserOptionalDep): 현재 로그인한 사용자 정보 - session (DbSessionDep): 데이터베이스 세션 - - Returns: - CalendarOut | CalendarDetailOut: 조회된 사용자 정보 - """ stmt = select(User).where(User.username == host_username) result = await session.execute(stmt) host = result.scalar_one_or_none() + if host is None: + raise HostNotFoundError() stmt = select(Calendar).where(Calendar.host_id == host.id) result = await session.execute(stmt) calendar = result.scalar_one_or_none() - if user is not None and user.id == host_id: + if calendar is None: + raise CalendarNotFoundError() + + if user is not None and user.id == host.id: return CalendarDetailOut.model_validate(calendar) - return CalendarOut.model_validate(calendar) - \ No newline at end of file diff --git a/appserver/apps/calendar/exceptions.py b/appserver/apps/calendar/exceptions.py new file mode 100644 index 0000000..cf0dcc7 --- /dev/null +++ b/appserver/apps/calendar/exceptions.py @@ -0,0 +1,17 @@ +from fastapi import HTTPException, status + + +class HostNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="호스트가 없습니다.", + ) + + +class CalendarNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="캘린더가 없습니다.", + ) diff --git a/appserver/apps/calendar/schemas.py b/appserver/apps/calendar/schemas.py index e69de29..0d5e229 100644 --- a/appserver/apps/calendar/schemas.py +++ b/appserver/apps/calendar/schemas.py @@ -0,0 +1,13 @@ +from pydantic import AwareDatetime +from sqlmodel import SQLModel + +class CalendarOut(SQLModel): + topics: list[str] + description: str + +class CalendarDetailOut(CalendarOut): + host_id: int + google_calendar_id: str + created_at: AwareDatetime + updated_at: AwareDatetime + diff --git a/pyproject.toml b/pyproject.toml index 07b1f8a..6a2bc08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ python_files = ["tests.py", "test_*.py"] rootdir = "./" testpaths = ["./tests"] pythonpath = ["./"] +norecursedirs = ["alembic"] filterwarnings = ["error", "ignore::DeprecationWarning:etcd3.*:"] log_cli = true log_cli_level = "WARNING" diff --git a/tests/apps/calendar/test_calendar.py b/tests/apps/calendar/test_calendar.py index f09874a..9136d5f 100644 --- a/tests/apps/calendar/test_calendar.py +++ b/tests/apps/calendar/test_calendar.py @@ -1,55 +1,11 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession +from appserver.apps.account.models import User from appserver.apps.calendar.models import Calendar from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut from appserver.apps.calendar.endpoints import host_calendar_detail - -async def test_호스트인_사용자의_username_으로_캘린더_정보를_가져온다( - host_user: User, - host_user_calendar: Calendar, - guest_user: User, - db_session: AsyncSession, -): - user = None - expected_type = CalendarOut - - result = await host_calendar_detail(host_user.username, db_session) - - assert isinstance(result, expected_type) - result_keys = frozenset(result.model_dump().keys()) - - expected_keys = frozenset(expected_type.model_fields.keys()) - assert result_keys == expected_keys - - assert result.topics == host_user_calendar.topics - assert result.description == host_user_calendar.description - - user = guest_user - expected_type = CalendarOut - # 반복 되는 코드 - result = await host_calendar_detail(host_user.username, user, db_session) - - assert isinstance(result, expected_type) - result_keys = frozenset(result.model_dump().keys()) - - expected_keys = frozenset(expected_type.model_fields.keys()) - assert result_keys == expected_keys - - assert result.topics == host_user_calendar.topics - assert result.description == host_user_calendar.description - - # 반복 되는 코드 - result = await host_calendar_detail(host_user.username, user, db_session) - - assert isinstance(result, expected_type) - result_keys = frozenset(result.model_dump().keys()) - - expected_keys = frozenset(expected_type.model_fields.keys()) - assert result_keys == expected_keys - - assert result.topics == host_user_calendar.topics - assert result.description == host_user_calendar.description - assert result.google_calendar_id == host_user_calendar.google_calendar_id +from appserver.apps.calendar.exceptions import HostNotFoundError +from appserver.apps.calendar.exceptions import CalendarNotFoundError @pytest.mark.parametrize("user_key, expected_type", [ ("host_user", CalendarDetailOut), @@ -81,4 +37,19 @@ async def test_호스트인_사용자의_username으로_캘린더_정보를_가 assert result.topics == host_user_calendar.topics assert result.description == host_user_calendar.description if expected_type is CalendarDetailOut: - assert result.google_calendar_id == host_user_calendar.google_calendar_id \ No newline at end of file + assert result.google_calendar_id == host_user_calendar.google_calendar_id + + +async def test_존재하지_않는_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + db_session: AsyncSession, +) -> None: + with pytest.raises(HostNotFoundError): + await host_calendar_detail("not_exist_user", None, db_session) + + +async def test_호스트가_아닌_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + guest_user: User, + db_session: AsyncSession, +) -> None: + with pytest.raises(CalendarNotFoundError): + await host_calendar_detail(guest_user.username, None, db_session) \ No newline at end of file diff --git a/tests/apps/calendar/test_calendar_api.py b/tests/apps/calendar/test_calendar_api.py new file mode 100644 index 0000000..80ef46f --- /dev/null +++ b/tests/apps/calendar/test_calendar_api.py @@ -0,0 +1,53 @@ +from fastapi import status +from fastapi.testclient import TestClient +import pytest +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut +from appserver.apps.calendar.endpoints import host_calendar_detail + +@pytest.mark.parametrize("user_key, expected_type", [ + ("host_user", CalendarDetailOut), + ("guest_user", CalendarOut), + (None, CalendarOut), +]) +async def test_호스트인_사용자의_username_으로_캘린더_정보를_가져온다( + user_key: str | None, + expected_type: type[CalendarOut | CalendarDetailOut], + host_user: User, + host_user_calendar: Calendar, + client: TestClient, + client_with_auth: TestClient, +) -> CalendarOut | CalendarDetailOut: + clients = { + "host_user": client_with_auth, + "guest_user": client, + None: client, + } + user_client = clients[user_key] + + response = user_client.get(f"/calendar/{host_user.username}") + result = response.json() + assert response.status_code == status.HTTP_200_OK + + expected_obj = expected_tpye.model_validate(result) + + assert expected_obj.topics == host_user_calendar.topics + assert expected_obj.description == host_user_calendar.description + if expected_type is CalendarDetailOut: + assert expected_obj.google_calendar_id == host_user_calendar.google_calendar_id + + +async def test_존재하지_않는_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + client: TestClient, +) -> None: + response = client.get("/calendar/not_exist_user") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_호스트가_아닌_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + guest_user: User, + client: TestClient, +) -> None: + response = client.get(f"/calendar/{guest_user.username}") + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/conftest.py b/tests/conftest.py index 91a0acc..4f0ea6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -124,13 +124,10 @@ async def guest_user(db_session: AsyncSession): await db_session.flush() return user -from appseerver.apps.calendar import models as calendar_models - @pytest.fixture() async def host_user_calendar(db_session: AsyncSession, host_user: account_models.User): calendar = calendar_models.Calendar( host_id=host_user.id, - google_calendar_id="푸딩캠프 캘린더입니다.", topics=["푸딩캠프", "푸딩캠프2"], google_calendar_id="1234567890", ) @@ -138,4 +135,4 @@ async def host_user_calendar(db_session: AsyncSession, host_user: account_models await db_session.commit() await db_session.refresh(host_user) await db_session.flush() - return calendar \ No newline at end of file + return calendar From 2482b6143bb753163fdcab8b2a8788542ce52935 Mon Sep 17 00:00:00 2001 From: okeunsu Date: Thu, 2 Apr 2026 05:14:16 +0900 Subject: [PATCH 11/11] chore: sync local project changes --- .gitignore | 425 ++-- .vscode/settings.json | 52 +- README.md | 1252 +++++------ alembic.ini | 294 +-- alembic/env.py | 154 +- alembic/script.py.mako | 64 +- .../versions/310ac765367e_initialization.py | 198 +- appserver/app.py | 16 +- appserver/apps/account/constants.py | 2 +- appserver/apps/account/deps.py | 138 +- appserver/apps/account/endpoints.py | 466 ++--- appserver/apps/account/exceptions.py | 92 +- appserver/apps/account/models.py | 258 +-- appserver/apps/account/schemas.py | 172 +- appserver/apps/account/utils.py | 128 +- appserver/apps/calendar/exceptions.py | 6 +- appserver/apps/calendar/models.py | 246 +-- appserver/apps/calendar/schemas.py | 22 +- appserver/db.py | 102 +- appserver/libs/datetime/calendar.py | 186 +- poetry.lock | 1848 ++++++++--------- pyproject.toml | 64 +- requirements.txt | 61 + study_plan.ipynb | 832 ++++---- tests/apps/account/test_endpoints.py | 216 +- tests/apps/account/test_login_api.py | 44 +- tests/apps/account/test_logout_api.py | 24 +- tests/apps/account/test_me_api.py | 80 +- tests/apps/account/test_signup.py | 172 +- tests/apps/account/test_signup_api.py | 70 +- tests/apps/account/test_unregister_api.py | 42 +- tests/apps/account/test_update_user_api.py | 110 +- tests/apps/calendar/test_calendar.py | 108 +- tests/apps/calendar/test_calendar_api.py | 106 +- tests/conftest.py | 260 +-- tests/libs/datetime/test_calendar.py | 112 +- tests/test_hello.py | 24 +- 37 files changed, 4256 insertions(+), 4190 deletions(-) create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index c118903..6549454 100644 --- a/.gitignore +++ b/.gitignore @@ -1,210 +1,215 @@ -example.py -*.db - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +example.py +*.db + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# claude +.claude/ + +.vscode/ diff --git a/.vscode/settings.json b/.vscode/settings.json index c1de8c6..9f602a2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,26 +1,26 @@ -{ - "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", - "python.testing.cwd": "${workspaceFolder}", - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true, - "pythonTestExplorer.testFramework": "pytest", - "python.testing.pytestPath": "./.venv/Scripts/pytest.exe", - "python.testing.pytestArgs": [ - "." - ], - "python.testing.autoTestDiscoverOnSaveEnabled": false, - "python.analysis.autoImportCompletions": true, - "python.analysis.indexing": true, - "python.analysis.completeFunctionParens": true, - "python.analysis.autoSearchPaths": true, - "python.analysis.useLibraryCodeForTypes": true, - "python.analysis.typeCheckingMode": "off", - "python.analysis.extraPaths": [ - "./appserver" - ], - "python.autoComplete.extraPaths": [ - "./appserver" - ], - "python.terminal.activateEnvironment": true, - "python-envs.pythonProjects": [] -} +{ + "python.defaultInterpreterPath": "./.venv/Scripts/python.exe", + "python.testing.cwd": "${workspaceFolder}", + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "pythonTestExplorer.testFramework": "pytest", + "python.testing.pytestPath": "./.venv/Scripts/pytest.exe", + "python.testing.pytestArgs": [ + "." + ], + "python.testing.autoTestDiscoverOnSaveEnabled": false, + "python.analysis.autoImportCompletions": true, + "python.analysis.indexing": true, + "python.analysis.completeFunctionParens": true, + "python.analysis.autoSearchPaths": true, + "python.analysis.useLibraryCodeForTypes": true, + "python.analysis.typeCheckingMode": "off", + "python.analysis.extraPaths": [ + "./appserver" + ], + "python.autoComplete.extraPaths": [ + "./appserver" + ], + "python.terminal.activateEnvironment": true, + "python-envs.pythonProjects": [] +} diff --git a/README.md b/README.md index 55b2740..173cc60 100644 --- a/README.md +++ b/README.md @@ -1,627 +1,627 @@ -# fastapi-project-study - -## 📚 Pytest Fixture 완벽 가이드 - -### 🤔 Fixture란? - -**Fixture**는 테스트를 실행하기 전에 필요한 **준비 작업(setup)**과 테스트 후 **정리 작업(teardown)**을 자동으로 처리해주는 **pytest의 테스트 전용 기능**입니다. - -쉽게 말하면: -- 테스트에 필요한 **재료(데이터, 객체, 연결 등)**를 미리 준비해주는 함수 -- 테스트가 끝나면 자동으로 **정리**까지 해줌 -- 여러 테스트에서 **재사용** 가능 - -> ⚠️ **중요**: Fixture는 **테스트 코드(`tests/` 디렉토리)에서만** 사용됩니다. 실제 프로덕션 코드(`appserver/`)에서는 사용하지 않습니다! - ---- - -### 🎯 왜 Fixture를 사용하나? - -#### ❌ Fixture 없이 테스트 작성하면: - -```python -def test_user_creation(): - # 매번 DB 연결 설정 - engine = create_engine("sqlite:///:memory:") - SQLModel.metadata.create_all(engine) - session = Session(engine) - - # 실제 테스트 - user = User(username="test") - session.add(user) - session.commit() - - # 정리 - session.close() - engine.dispose() - -def test_user_query(): - # 또 똑같이 DB 연결 설정 (중복!) - engine = create_engine("sqlite:///:memory:") - SQLModel.metadata.create_all(engine) - session = Session(engine) - - # 실제 테스트 - user = session.query(User).first() - - # 정리 - session.close() - engine.dispose() -``` - -**문제점:** -- 코드 중복이 심함 -- 테스트 코드가 길고 복잡함 -- 정리 작업을 깜빡하면 리소스 누수 발생 - ---- - -#### ✅ Fixture 사용하면: - -```python -@pytest.fixture -def db_session(): - # Setup: 준비 작업 - engine = create_engine("sqlite:///:memory:") - SQLModel.metadata.create_all(engine) - session = Session(engine) - - yield session # 테스트에 session 제공 - - # Teardown: 정리 작업 (자동 실행) - session.close() - engine.dispose() - -# 테스트 함수에서 fixture 이름을 파라미터로 받으면 자동 주입! -def test_user_creation(db_session): - user = User(username="test") - db_session.add(user) - db_session.commit() - # 정리는 자동으로 됨! - -def test_user_query(db_session): - user = db_session.query(User).first() - # 정리는 자동으로 됨! -``` - -**장점:** -- 코드 중복 제거 -- 테스트 코드가 간결해짐 -- 정리 작업 자동화 (리소스 누수 방지) - ---- - -### 🔧 Fixture 기본 사용법 - -#### 1️⃣ **기본 Fixture 정의** - -```python -import pytest - -@pytest.fixture -def sample_data(): - """간단한 데이터를 제공하는 fixture""" - return {"name": "Alice", "age": 30} - -def test_sample(sample_data): - # sample_data fixture가 자동으로 주입됨 - assert sample_data["name"] == "Alice" - assert sample_data["age"] == 30 -``` - -**작동 방식:** -1. pytest가 `test_sample` 함수를 발견 -2. 파라미터 `sample_data`를 확인 -3. 같은 이름의 fixture를 찾아서 실행 -4. fixture의 반환값을 테스트 함수에 전달 - ---- - -#### 2️⃣ **Setup/Teardown이 있는 Fixture** - -```python -@pytest.fixture -def temp_file(): - # Setup: 파일 생성 - file_path = "test_temp.txt" - with open(file_path, "w") as f: - f.write("test data") - - yield file_path # 테스트에 파일 경로 제공 - - # Teardown: 파일 삭제 (테스트 후 자동 실행) - import os - os.remove(file_path) - -def test_file_read(temp_file): - with open(temp_file, "r") as f: - content = f.read() - assert content == "test data" - # 테스트 끝나면 파일이 자동으로 삭제됨! -``` - -**`yield` 키워드:** -- `yield` 앞: Setup (테스트 전 실행) -- `yield` 값: 테스트 함수에 전달 -- `yield` 뒤: Teardown (테스트 후 실행) - ---- - -#### 3️⃣ **Fixture가 다른 Fixture를 사용** - -```python -@pytest.fixture -def database_engine(): - engine = create_engine("sqlite:///:memory:") - yield engine - engine.dispose() - -@pytest.fixture -def database_session(database_engine): # 다른 fixture를 파라미터로! - session = Session(database_engine) - yield session - session.close() - -def test_with_session(database_session): - # database_engine → database_session → test 순서로 실행 - user = User(username="test") - database_session.add(user) -``` - -**의존성 체인:** -``` -database_engine (먼저 실행) - ↓ -database_session (engine을 받아서 실행) - ↓ -test_with_session (session을 받아서 실행) -``` - ---- - -### 🎨 Fixture Scope (범위) - -Fixture가 **언제 생성되고 언제 정리되는지** 제어할 수 있습니다. - -```python -@pytest.fixture(scope="function") # 기본값: 각 테스트마다 새로 생성 -def function_scope(): - print("Setup") - yield "data" - print("Teardown") - -@pytest.fixture(scope="module") # 모듈(파일)당 1번만 생성 -def module_scope(): - print("Setup once per module") - yield "data" - print("Teardown once per module") - -@pytest.fixture(scope="session") # 전체 테스트 세션당 1번만 생성 -def session_scope(): - print("Setup once per session") - yield "data" - print("Teardown once per session") -``` - -| Scope | 생성 시점 | 정리 시점 | 사용 예시 | -|-------|----------|----------|----------| -| `function` | 각 테스트 함수마다 | 각 테스트 종료 후 | DB 세션, 임시 파일 | -| `class` | 각 테스트 클래스마다 | 클래스 테스트 종료 후 | 클래스별 설정 | -| `module` | 각 테스트 파일마다 | 파일 테스트 종료 후 | DB 연결 풀 | -| `session` | 전체 테스트 시작 시 | 모든 테스트 종료 후 | 테스트 서버 | - ---- - -### 🎨 Fixture 고급 옵션 - -#### 1️⃣ **`autouse=True` - 자동 실행** - -모든 테스트에서 **자동으로 실행**되어야 하는 fixture가 있다면 이 옵션을 사용합니다. -파라미터로 명시하지 않아도 자동으로 실행됩니다. - -```python -@pytest.fixture(autouse=True) -def setup_log(): - """모든 테스트 전에 자동으로 실행""" - print("\n[LOG] 테스트를 시작합니다...") - yield - print("[LOG] 테스트를 종료합니다...") - -def test_something(): - # setup_log를 파라미터로 받지 않아도 자동 실행됨! - assert True -``` - -**사용 예시:** -- 로깅 설정 -- 환경 변수 초기화 -- 테스트 데이터베이스 초기화 (우리 프로젝트의 `db_session`처럼) - -```python -# 우리 프로젝트의 실제 예시 -@pytest.fixture(autouse=True) # 모든 테스트에서 자동으로 DB 세션 생성 -async def db_session(): - # ... -``` - ---- - -#### 2️⃣ **`name` - Fixture 이름 지정** - -Fixture 함수 이름이 너무 길거나, 테스트 코드에서 더 직관적인 이름을 쓰고 싶을 때 사용합니다. - -```python -@pytest.fixture(name="db") -def a_very_long_database_session_fixture_name(): - """함수명은 길지만, 'db'라는 짧은 이름으로 사용 가능""" - engine = create_engine("sqlite:///:memory:") - session = Session(engine) - yield session - session.close() - -def test_user(db): # 함수명 대신 'db'라는 이름으로 주입! - user = User(username="test") - db.add(user) - assert db is not None -``` - -**언제 사용하나:** -- 함수명이 너무 길 때 -- 더 직관적인 이름을 사용하고 싶을 때 -- 레거시 코드와의 호환성을 위해 - ---- - -#### 3️⃣ **비동기 Fixture 주의사항** - -FastAPI는 비동기(`async/await`)를 많이 사용하므로, Fixture에서도 비동기를 사용할 때 주의가 필요합니다. - -**필수 설정:** - -1. **`pytest-asyncio` 설치** - ```bash - pip install pytest-asyncio - ``` - -2. **`pyproject.toml` 설정** - ```toml - [tool.pytest.ini_options] - asyncio_mode = "auto" # 비동기 테스트 자동 감지 - ``` - -3. **비동기 Fixture 작성** - ```python - @pytest.fixture - async def async_client(): - """비동기 fixture는 async def로 정의""" - async with AsyncClient(app=app, base_url="http://test") as client: - yield client - - async def test_api(async_client): - """비동기 테스트 함수도 async def로 정의""" - response = await async_client.get("/users/test") - assert response.status_code == 200 - ``` - -**주의사항:** - -| 구분 | 동기 Fixture | 비동기 Fixture | -|------|-------------|---------------| -| 정의 | `def fixture()` | `async def fixture()` | -| 사용 | 동기 테스트에서 사용 | 비동기 테스트에서 사용 | -| 의존성 | 동기 fixture만 의존 가능 | 비동기/동기 모두 의존 가능 | - -**우리 프로젝트 예시:** -```python -# ✅ 올바른 사용 -@pytest.fixture -async def db_session(db_engine): # 비동기 fixture - async with session_factory() as session: - yield session - -async def test_user(db_session): # 비동기 테스트 - user = User(username="test") - db_session.add(user) - await db_session.commit() # await 사용 - -# ❌ 잘못된 사용 -def test_user(db_session): # 동기 테스트에서 비동기 fixture 사용 불가 - await db_session.commit() # SyntaxError! -``` - -**동기/비동기 혼용 시 해결책:** -```python -# 비동기 엔진을 동기 fixture에서 사용하려면 -@pytest.fixture -def fastapi_app(db_engine: AsyncEngine): # 동기 fixture가 비동기 엔진 의존 - app = FastAPI() - - # 내부에서 비동기 함수 정의 (클로저) - async def override_use_session(): - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - - app.dependency_overrides[use_session] = override_use_session - return app -``` - ---- - -### 🚀 실제 프로젝트 Fixture 구조 - -우리 프로젝트의 `tests/conftest.py`: - -```python -# 1. 최상위: DB 엔진 (모듈당 1번 생성) -@pytest.fixture(scope="function") -async def db_engine(): - """인메모리 SQLite 엔진 생성""" - dsn = "sqlite+aiosqlite:///:memory:" - engine = create_async_engine(dsn) - - # 테이블 생성 - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - - yield engine # 엔진 제공 - - # 정리: 테이블 삭제, 엔진 종료 - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.drop_all) - await engine.dispose() - - -# 2. DB 세션 (비동기 테스트용) -@pytest.fixture(scope="function") -async def db_session(db_engine): - """각 테스트마다 독립적인 세션 제공""" - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - await session.rollback() # 테스트 후 롤백 - - -# 3. FastAPI 앱 (HTTP 테스트용) -@pytest.fixture() -def fastapi_app(db_engine): - """테스트용 FastAPI 앱 생성""" - app = FastAPI() - include_routers(app) - - # 의존성 오버라이드: 실제 DB 대신 테스트 DB 사용 - async def override_use_session(): - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - - app.dependency_overrides[use_session] = override_use_session - return app - - -# 4. HTTP 클라이언트 -@pytest.fixture() -def client(fastapi_app): - """테스트용 HTTP 클라이언트""" - with TestClient(fastapi_app) as client: - yield client -``` - ---- - -### 🔄 실행 흐름 예시 - -```python -async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): - # 1. 테스트 데이터 준비 - user = User(username="test") - db_session.add(user) - await db_session.commit() - - # 2. HTTP API 호출 - response = client.get(f"/account/users/{user.username}") - assert response.status_code == 200 -``` - -**실행 순서:** - -``` -1. db_engine fixture 실행 - ├─ 엔진 생성 - └─ 테이블 생성 (users, calendars 등) - -2. db_session fixture 실행 - ├─ db_engine을 받아서 세션 생성 - └─ 테스트에 세션 제공 - -3. fastapi_app fixture 실행 - ├─ db_engine을 받아서 앱 생성 - └─ 의존성 오버라이드 설정 - -4. client fixture 실행 - ├─ fastapi_app을 받아서 TestClient 생성 - └─ 테스트에 client 제공 - -5. 테스트 실행 - ├─ db_session으로 사용자 생성 - └─ client로 API 호출 - -6. Teardown (역순으로 정리) - ├─ client 정리 - ├─ fastapi_app 정리 - ├─ db_session 롤백 - └─ db_engine 정리 (테이블 삭제, 엔진 종료) -``` - ---- - -### 💡 핵심 개념 정리 - -1. **Fixture = 테스트 재료 준비 + 자동 정리** - - `yield` 앞: Setup - - `yield` 값: 테스트에 전달 - - `yield` 뒤: Teardown - -2. **의존성 주입** - - 테스트 함수 파라미터에 fixture 이름 작성 - - pytest가 자동으로 fixture 실행 후 결과 전달 - -3. **Fixture 체인** - - Fixture가 다른 fixture를 사용 가능 - - 의존성 순서대로 자동 실행 - -4. **Scope로 생명주기 제어** - - `function`: 매 테스트마다 (기본값) - - `module`: 파일당 1번 - - `session`: 전체 테스트당 1번 - -5. **`conftest.py`** - - 여러 테스트 파일에서 공유할 fixture 정의 - - pytest가 자동으로 인식 - ---- - -### 🎓 학습 팁 - -1. **간단한 fixture부터 시작** - ```python - @pytest.fixture - def simple_data(): - return [1, 2, 3] - ``` - -2. **print로 실행 순서 확인** - ```python - @pytest.fixture - def my_fixture(): - print("Setup!") - yield "data" - print("Teardown!") - ``` - -3. **실제 사용 사례 연습** - - 파일 생성/삭제 - - DB 연결/종료 - - API 서버 시작/종료 - -4. **공식 문서 참고** - - https://docs.pytest.org/en/stable/fixture.html - ---- - -## 🔀 Fixture vs 프로덕션 코드 - -### ⚡ 핵심 차이점 - -| 구분 | 테스트 코드 | 프로덕션 코드 | -|------|------------|--------------| -| **사용 기술** | Pytest Fixture | FastAPI Dependency Injection | -| **사용 위치** | `tests/` 디렉토리 | `appserver/` 디렉토리 | -| **목적** | 테스트 환경 준비 | 실제 서비스 로직 | -| **DB** | 인메모리 SQLite | 실제 DB (PostgreSQL 등) | -| **생명주기** | 테스트마다 생성/삭제 | 앱 실행 중 유지 | - ---- - -### 📝 비교 예시 - -#### 1️⃣ **테스트 코드에서 DB 세션 사용** (Pytest Fixture) - -```python -# tests/conftest.py -@pytest.fixture -async def db_session(db_engine): - """테스트용 DB 세션 - 인메모리 SQLite 사용""" - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - await session.rollback() # 테스트 후 롤백 - -# tests/test_user.py -async def test_create_user(db_session: AsyncSession): - """Fixture로 세션 주입받음""" - user = User(username="test") - db_session.add(user) - await db_session.commit() -``` - ---- - -#### 2️⃣ **프로덕션 코드에서 DB 세션 사용** (FastAPI Dependency) - -```python -# appserver/db.py -async def use_session(): - """실제 서비스용 DB 세션 - 실제 DB 사용""" - async with session_factory() as session: - yield session - -# appserver/apps/account/endpoints.py -@router.get("/users/{username}") -async def user_detail( - username: str, - session: AsyncSession = Depends(use_session) # FastAPI 의존성 주입 -): - """FastAPI Depends로 세션 주입받음""" - user = await session.get(User, username) - if not user: - raise HTTPException(status_code=404) - return user -``` - ---- - -### 🎯 왜 테스트에서는 Fixture를 사용하나? - -1. **격리된 환경** - - 실제 DB를 건드리지 않음 - - 인메모리 DB로 빠른 테스트 - - 테스트마다 깨끗한 상태 - -2. **의존성 오버라이드** - ```python - # 프로덕션: 실제 DB 사용 - app.dependency_overrides[use_session] = 실제_세션 - - # 테스트: 테스트 DB 사용 - app.dependency_overrides[use_session] = 테스트_세션 - ``` - -3. **자동 정리** - - 테스트 후 자동으로 데이터 삭제 - - 리소스 누수 방지 - ---- - -### 📂 디렉토리 구조로 이해하기 - -``` -fastapi-project-study/ -├── appserver/ # 프로덕션 코드 -│ ├── db.py # use_session (FastAPI Depends) -│ └── apps/ -│ └── account/ -│ └── endpoints.py # Depends(use_session) 사용 -│ -└── tests/ # 테스트 코드 - ├── conftest.py # db_session (Pytest Fixture) - └── apps/ - └── account/ - └── test_endpoints.py # db_session fixture 사용 -``` - ---- - -### 💡 정리 - -- **Pytest Fixture** = 테스트 전용 도구 - - `tests/` 디렉토리에서만 사용 - - `@pytest.fixture` 데코레이터 - - 테스트 환경 준비 및 정리 - -- **FastAPI Dependency** = 프로덕션 코드의 의존성 주입 - - `appserver/` 디렉토리에서 사용 - - `Depends()` 함수 - - 실제 서비스 로직 - +# fastapi-project-study + +## 📚 Pytest Fixture 완벽 가이드 + +### 🤔 Fixture란? + +**Fixture**는 테스트를 실행하기 전에 필요한 **준비 작업(setup)**과 테스트 후 **정리 작업(teardown)**을 자동으로 처리해주는 **pytest의 테스트 전용 기능**입니다. + +쉽게 말하면: +- 테스트에 필요한 **재료(데이터, 객체, 연결 등)**를 미리 준비해주는 함수 +- 테스트가 끝나면 자동으로 **정리**까지 해줌 +- 여러 테스트에서 **재사용** 가능 + +> ⚠️ **중요**: Fixture는 **테스트 코드(`tests/` 디렉토리)에서만** 사용됩니다. 실제 프로덕션 코드(`appserver/`)에서는 사용하지 않습니다! + +--- + +### 🎯 왜 Fixture를 사용하나? + +#### ❌ Fixture 없이 테스트 작성하면: + +```python +def test_user_creation(): + # 매번 DB 연결 설정 + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + session = Session(engine) + + # 실제 테스트 + user = User(username="test") + session.add(user) + session.commit() + + # 정리 + session.close() + engine.dispose() + +def test_user_query(): + # 또 똑같이 DB 연결 설정 (중복!) + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + session = Session(engine) + + # 실제 테스트 + user = session.query(User).first() + + # 정리 + session.close() + engine.dispose() +``` + +**문제점:** +- 코드 중복이 심함 +- 테스트 코드가 길고 복잡함 +- 정리 작업을 깜빡하면 리소스 누수 발생 + +--- + +#### ✅ Fixture 사용하면: + +```python +@pytest.fixture +def db_session(): + # Setup: 준비 작업 + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + session = Session(engine) + + yield session # 테스트에 session 제공 + + # Teardown: 정리 작업 (자동 실행) + session.close() + engine.dispose() + +# 테스트 함수에서 fixture 이름을 파라미터로 받으면 자동 주입! +def test_user_creation(db_session): + user = User(username="test") + db_session.add(user) + db_session.commit() + # 정리는 자동으로 됨! + +def test_user_query(db_session): + user = db_session.query(User).first() + # 정리는 자동으로 됨! +``` + +**장점:** +- 코드 중복 제거 +- 테스트 코드가 간결해짐 +- 정리 작업 자동화 (리소스 누수 방지) + +--- + +### 🔧 Fixture 기본 사용법 + +#### 1️⃣ **기본 Fixture 정의** + +```python +import pytest + +@pytest.fixture +def sample_data(): + """간단한 데이터를 제공하는 fixture""" + return {"name": "Alice", "age": 30} + +def test_sample(sample_data): + # sample_data fixture가 자동으로 주입됨 + assert sample_data["name"] == "Alice" + assert sample_data["age"] == 30 +``` + +**작동 방식:** +1. pytest가 `test_sample` 함수를 발견 +2. 파라미터 `sample_data`를 확인 +3. 같은 이름의 fixture를 찾아서 실행 +4. fixture의 반환값을 테스트 함수에 전달 + +--- + +#### 2️⃣ **Setup/Teardown이 있는 Fixture** + +```python +@pytest.fixture +def temp_file(): + # Setup: 파일 생성 + file_path = "test_temp.txt" + with open(file_path, "w") as f: + f.write("test data") + + yield file_path # 테스트에 파일 경로 제공 + + # Teardown: 파일 삭제 (테스트 후 자동 실행) + import os + os.remove(file_path) + +def test_file_read(temp_file): + with open(temp_file, "r") as f: + content = f.read() + assert content == "test data" + # 테스트 끝나면 파일이 자동으로 삭제됨! +``` + +**`yield` 키워드:** +- `yield` 앞: Setup (테스트 전 실행) +- `yield` 값: 테스트 함수에 전달 +- `yield` 뒤: Teardown (테스트 후 실행) + +--- + +#### 3️⃣ **Fixture가 다른 Fixture를 사용** + +```python +@pytest.fixture +def database_engine(): + engine = create_engine("sqlite:///:memory:") + yield engine + engine.dispose() + +@pytest.fixture +def database_session(database_engine): # 다른 fixture를 파라미터로! + session = Session(database_engine) + yield session + session.close() + +def test_with_session(database_session): + # database_engine → database_session → test 순서로 실행 + user = User(username="test") + database_session.add(user) +``` + +**의존성 체인:** +``` +database_engine (먼저 실행) + ↓ +database_session (engine을 받아서 실행) + ↓ +test_with_session (session을 받아서 실행) +``` + +--- + +### 🎨 Fixture Scope (범위) + +Fixture가 **언제 생성되고 언제 정리되는지** 제어할 수 있습니다. + +```python +@pytest.fixture(scope="function") # 기본값: 각 테스트마다 새로 생성 +def function_scope(): + print("Setup") + yield "data" + print("Teardown") + +@pytest.fixture(scope="module") # 모듈(파일)당 1번만 생성 +def module_scope(): + print("Setup once per module") + yield "data" + print("Teardown once per module") + +@pytest.fixture(scope="session") # 전체 테스트 세션당 1번만 생성 +def session_scope(): + print("Setup once per session") + yield "data" + print("Teardown once per session") +``` + +| Scope | 생성 시점 | 정리 시점 | 사용 예시 | +|-------|----------|----------|----------| +| `function` | 각 테스트 함수마다 | 각 테스트 종료 후 | DB 세션, 임시 파일 | +| `class` | 각 테스트 클래스마다 | 클래스 테스트 종료 후 | 클래스별 설정 | +| `module` | 각 테스트 파일마다 | 파일 테스트 종료 후 | DB 연결 풀 | +| `session` | 전체 테스트 시작 시 | 모든 테스트 종료 후 | 테스트 서버 | + +--- + +### 🎨 Fixture 고급 옵션 + +#### 1️⃣ **`autouse=True` - 자동 실행** + +모든 테스트에서 **자동으로 실행**되어야 하는 fixture가 있다면 이 옵션을 사용합니다. +파라미터로 명시하지 않아도 자동으로 실행됩니다. + +```python +@pytest.fixture(autouse=True) +def setup_log(): + """모든 테스트 전에 자동으로 실행""" + print("\n[LOG] 테스트를 시작합니다...") + yield + print("[LOG] 테스트를 종료합니다...") + +def test_something(): + # setup_log를 파라미터로 받지 않아도 자동 실행됨! + assert True +``` + +**사용 예시:** +- 로깅 설정 +- 환경 변수 초기화 +- 테스트 데이터베이스 초기화 (우리 프로젝트의 `db_session`처럼) + +```python +# 우리 프로젝트의 실제 예시 +@pytest.fixture(autouse=True) # 모든 테스트에서 자동으로 DB 세션 생성 +async def db_session(): + # ... +``` + +--- + +#### 2️⃣ **`name` - Fixture 이름 지정** + +Fixture 함수 이름이 너무 길거나, 테스트 코드에서 더 직관적인 이름을 쓰고 싶을 때 사용합니다. + +```python +@pytest.fixture(name="db") +def a_very_long_database_session_fixture_name(): + """함수명은 길지만, 'db'라는 짧은 이름으로 사용 가능""" + engine = create_engine("sqlite:///:memory:") + session = Session(engine) + yield session + session.close() + +def test_user(db): # 함수명 대신 'db'라는 이름으로 주입! + user = User(username="test") + db.add(user) + assert db is not None +``` + +**언제 사용하나:** +- 함수명이 너무 길 때 +- 더 직관적인 이름을 사용하고 싶을 때 +- 레거시 코드와의 호환성을 위해 + +--- + +#### 3️⃣ **비동기 Fixture 주의사항** + +FastAPI는 비동기(`async/await`)를 많이 사용하므로, Fixture에서도 비동기를 사용할 때 주의가 필요합니다. + +**필수 설정:** + +1. **`pytest-asyncio` 설치** + ```bash + pip install pytest-asyncio + ``` + +2. **`pyproject.toml` 설정** + ```toml + [tool.pytest.ini_options] + asyncio_mode = "auto" # 비동기 테스트 자동 감지 + ``` + +3. **비동기 Fixture 작성** + ```python + @pytest.fixture + async def async_client(): + """비동기 fixture는 async def로 정의""" + async with AsyncClient(app=app, base_url="http://test") as client: + yield client + + async def test_api(async_client): + """비동기 테스트 함수도 async def로 정의""" + response = await async_client.get("/users/test") + assert response.status_code == 200 + ``` + +**주의사항:** + +| 구분 | 동기 Fixture | 비동기 Fixture | +|------|-------------|---------------| +| 정의 | `def fixture()` | `async def fixture()` | +| 사용 | 동기 테스트에서 사용 | 비동기 테스트에서 사용 | +| 의존성 | 동기 fixture만 의존 가능 | 비동기/동기 모두 의존 가능 | + +**우리 프로젝트 예시:** +```python +# ✅ 올바른 사용 +@pytest.fixture +async def db_session(db_engine): # 비동기 fixture + async with session_factory() as session: + yield session + +async def test_user(db_session): # 비동기 테스트 + user = User(username="test") + db_session.add(user) + await db_session.commit() # await 사용 + +# ❌ 잘못된 사용 +def test_user(db_session): # 동기 테스트에서 비동기 fixture 사용 불가 + await db_session.commit() # SyntaxError! +``` + +**동기/비동기 혼용 시 해결책:** +```python +# 비동기 엔진을 동기 fixture에서 사용하려면 +@pytest.fixture +def fastapi_app(db_engine: AsyncEngine): # 동기 fixture가 비동기 엔진 의존 + app = FastAPI() + + # 내부에서 비동기 함수 정의 (클로저) + async def override_use_session(): + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + + app.dependency_overrides[use_session] = override_use_session + return app +``` + +--- + +### 🚀 실제 프로젝트 Fixture 구조 + +우리 프로젝트의 `tests/conftest.py`: + +```python +# 1. 최상위: DB 엔진 (모듈당 1번 생성) +@pytest.fixture(scope="function") +async def db_engine(): + """인메모리 SQLite 엔진 생성""" + dsn = "sqlite+aiosqlite:///:memory:" + engine = create_async_engine(dsn) + + # 테이블 생성 + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + yield engine # 엔진 제공 + + # 정리: 테이블 삭제, 엔진 종료 + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await engine.dispose() + + +# 2. DB 세션 (비동기 테스트용) +@pytest.fixture(scope="function") +async def db_session(db_engine): + """각 테스트마다 독립적인 세션 제공""" + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + await session.rollback() # 테스트 후 롤백 + + +# 3. FastAPI 앱 (HTTP 테스트용) +@pytest.fixture() +def fastapi_app(db_engine): + """테스트용 FastAPI 앱 생성""" + app = FastAPI() + include_routers(app) + + # 의존성 오버라이드: 실제 DB 대신 테스트 DB 사용 + async def override_use_session(): + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + + app.dependency_overrides[use_session] = override_use_session + return app + + +# 4. HTTP 클라이언트 +@pytest.fixture() +def client(fastapi_app): + """테스트용 HTTP 클라이언트""" + with TestClient(fastapi_app) as client: + yield client +``` + +--- + +### 🔄 실행 흐름 예시 + +```python +async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): + # 1. 테스트 데이터 준비 + user = User(username="test") + db_session.add(user) + await db_session.commit() + + # 2. HTTP API 호출 + response = client.get(f"/account/users/{user.username}") + assert response.status_code == 200 +``` + +**실행 순서:** + +``` +1. db_engine fixture 실행 + ├─ 엔진 생성 + └─ 테이블 생성 (users, calendars 등) + +2. db_session fixture 실행 + ├─ db_engine을 받아서 세션 생성 + └─ 테스트에 세션 제공 + +3. fastapi_app fixture 실행 + ├─ db_engine을 받아서 앱 생성 + └─ 의존성 오버라이드 설정 + +4. client fixture 실행 + ├─ fastapi_app을 받아서 TestClient 생성 + └─ 테스트에 client 제공 + +5. 테스트 실행 + ├─ db_session으로 사용자 생성 + └─ client로 API 호출 + +6. Teardown (역순으로 정리) + ├─ client 정리 + ├─ fastapi_app 정리 + ├─ db_session 롤백 + └─ db_engine 정리 (테이블 삭제, 엔진 종료) +``` + +--- + +### 💡 핵심 개념 정리 + +1. **Fixture = 테스트 재료 준비 + 자동 정리** + - `yield` 앞: Setup + - `yield` 값: 테스트에 전달 + - `yield` 뒤: Teardown + +2. **의존성 주입** + - 테스트 함수 파라미터에 fixture 이름 작성 + - pytest가 자동으로 fixture 실행 후 결과 전달 + +3. **Fixture 체인** + - Fixture가 다른 fixture를 사용 가능 + - 의존성 순서대로 자동 실행 + +4. **Scope로 생명주기 제어** + - `function`: 매 테스트마다 (기본값) + - `module`: 파일당 1번 + - `session`: 전체 테스트당 1번 + +5. **`conftest.py`** + - 여러 테스트 파일에서 공유할 fixture 정의 + - pytest가 자동으로 인식 + +--- + +### 🎓 학습 팁 + +1. **간단한 fixture부터 시작** + ```python + @pytest.fixture + def simple_data(): + return [1, 2, 3] + ``` + +2. **print로 실행 순서 확인** + ```python + @pytest.fixture + def my_fixture(): + print("Setup!") + yield "data" + print("Teardown!") + ``` + +3. **실제 사용 사례 연습** + - 파일 생성/삭제 + - DB 연결/종료 + - API 서버 시작/종료 + +4. **공식 문서 참고** + - https://docs.pytest.org/en/stable/fixture.html + +--- + +## 🔀 Fixture vs 프로덕션 코드 + +### ⚡ 핵심 차이점 + +| 구분 | 테스트 코드 | 프로덕션 코드 | +|------|------------|--------------| +| **사용 기술** | Pytest Fixture | FastAPI Dependency Injection | +| **사용 위치** | `tests/` 디렉토리 | `appserver/` 디렉토리 | +| **목적** | 테스트 환경 준비 | 실제 서비스 로직 | +| **DB** | 인메모리 SQLite | 실제 DB (PostgreSQL 등) | +| **생명주기** | 테스트마다 생성/삭제 | 앱 실행 중 유지 | + +--- + +### 📝 비교 예시 + +#### 1️⃣ **테스트 코드에서 DB 세션 사용** (Pytest Fixture) + +```python +# tests/conftest.py +@pytest.fixture +async def db_session(db_engine): + """테스트용 DB 세션 - 인메모리 SQLite 사용""" + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + await session.rollback() # 테스트 후 롤백 + +# tests/test_user.py +async def test_create_user(db_session: AsyncSession): + """Fixture로 세션 주입받음""" + user = User(username="test") + db_session.add(user) + await db_session.commit() +``` + +--- + +#### 2️⃣ **프로덕션 코드에서 DB 세션 사용** (FastAPI Dependency) + +```python +# appserver/db.py +async def use_session(): + """실제 서비스용 DB 세션 - 실제 DB 사용""" + async with session_factory() as session: + yield session + +# appserver/apps/account/endpoints.py +@router.get("/users/{username}") +async def user_detail( + username: str, + session: AsyncSession = Depends(use_session) # FastAPI 의존성 주입 +): + """FastAPI Depends로 세션 주입받음""" + user = await session.get(User, username) + if not user: + raise HTTPException(status_code=404) + return user +``` + +--- + +### 🎯 왜 테스트에서는 Fixture를 사용하나? + +1. **격리된 환경** + - 실제 DB를 건드리지 않음 + - 인메모리 DB로 빠른 테스트 + - 테스트마다 깨끗한 상태 + +2. **의존성 오버라이드** + ```python + # 프로덕션: 실제 DB 사용 + app.dependency_overrides[use_session] = 실제_세션 + + # 테스트: 테스트 DB 사용 + app.dependency_overrides[use_session] = 테스트_세션 + ``` + +3. **자동 정리** + - 테스트 후 자동으로 데이터 삭제 + - 리소스 누수 방지 + +--- + +### 📂 디렉토리 구조로 이해하기 + +``` +fastapi-project-study/ +├── appserver/ # 프로덕션 코드 +│ ├── db.py # use_session (FastAPI Depends) +│ └── apps/ +│ └── account/ +│ └── endpoints.py # Depends(use_session) 사용 +│ +└── tests/ # 테스트 코드 + ├── conftest.py # db_session (Pytest Fixture) + └── apps/ + └── account/ + └── test_endpoints.py # db_session fixture 사용 +``` + +--- + +### 💡 정리 + +- **Pytest Fixture** = 테스트 전용 도구 + - `tests/` 디렉토리에서만 사용 + - `@pytest.fixture` 데코레이터 + - 테스트 환경 준비 및 정리 + +- **FastAPI Dependency** = 프로덕션 코드의 의존성 주입 + - `appserver/` 디렉토리에서 사용 + - `Depends()` 함수 + - 실제 서비스 로직 + **둘 다 "의존성 주입" 개념을 사용하지만, 목적과 사용 위치가 다릅니다!** 🎯 \ No newline at end of file diff --git a/alembic.ini b/alembic.ini index 7f7f01d..137c002 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,147 +1,147 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts. -# this is typically a path given in POSIX (e.g. forward slashes) -# format, relative to the token %(here)s which refers to the location of this -# ini file -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. for multiple paths, the path separator -# is defined by "path_separator" below. -prepend_sys_path = . - - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the tzdata library which can be installed by adding -# `alembic[tz]` to the pip requirements. -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to /versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "path_separator" -# below. -# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions - -# path_separator; This indicates what character is used to split lists of file -# paths, including version_locations and prepend_sys_path within configparser -# files such as alembic.ini. -# The default rendered in new alembic.ini files is "os", which uses os.pathsep -# to provide os-dependent path splitting. -# -# Note that in order to support legacy alembic.ini files, this default does NOT -# take place if path_separator is not present in alembic.ini. If this -# option is omitted entirely, fallback logic is as follows: -# -# 1. Parsing of the version_locations option falls back to using the legacy -# "version_path_separator" key, which if absent then falls back to the legacy -# behavior of splitting on spaces and/or commas. -# 2. Parsing of the prepend_sys_path option falls back to the legacy -# behavior of splitting on spaces, commas, or colons. -# -# Valid values for path_separator are: -# -# path_separator = : -# path_separator = ; -# path_separator = space -# path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -# sqlalchemy.url = driver://user:pass@localhost/dbname - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module -# hooks = ruff -# ruff.type = module -# ruff.module = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Alternatively, use the exec runner to execute a binary found on your PATH -# hooks = ruff -# ruff.type = exec -# ruff.executable = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration. This is also consumed by the user-maintained -# env.py script only. -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py index f7811b5..7661dc0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,78 +1,78 @@ -import asyncio -from logging.config import fileConfig - -from sqlalchemy.ext.asyncio import AsyncEngine -from sqlalchemy import engine_from_config -from sqlalchemy import pool -from sqlmodel import SQLModel -from sqlalchemy.engine import Connection - -# ------------------------------------------------------------------- -# 1. 모델 및 설정 로드 (Alembic이 "무엇을" "어디에" 적용할지 정의) -# ------------------------------------------------------------------- - -# 설계도(Model) 로드: Alembic이 테이블 구조를 파악할 수 있게 메모리에 올림 -from appserver.apps.account import models # noqa -from appserver.apps.calendar import models # noqa -# 연결 주소: 프로젝트 설정 파일에서 실제 DB 주소(DSN)를 가져옴 -from appserver.db import DSN -# Alembic 설정 객체: alembic.ini 파일의 내용에 접근할 수 있게 해줌 -from alembic import context - -config = context.config - -# 로깅 설정: alembic.ini의 설정을 바탕으로 로그를 출력합니다 (콘솔에 찍히는 내용들). -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# target_metadata: Alembic이 추적할 "기준" 설계도입니다. -target_metadata = SQLModel.metadata - -def run_migrations_offline() -> None: - """오프라인 모드: 실제 DB 연결 없이 SQL 스크립트 파일만 생성할 때 생성""" - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url or DSN, # ini 파일에 주소가 없으면 우리 프로젝트의 DSN 사용 - target_metadata=target_metadata, - literal_binds=True, # SQL을 텍스트 형태로 출력 - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) - - with context.begin_transaction(): - context.run_migrations() - - -async def run_migrations_online() -> None: - """온라인 모드: 실제 DB에 접속하여 테이블을 생성하거나 수정할 때 실행 (비동기 방식)""" - configuration = config.get_section(config.config_ini_section, {}) - - # 실제 DB 주소 주입: ini 설정보다 코드상의 DSN을 우선시함 - configuration["sqlalchemy.url"] = DSN - - # 비동기 엔진 생성: SQLAlchemy 설정을 바탕으로 DB 통로를 개설 - connectable = AsyncEngine( - engine_from_config( - configuration, - prefix="sqlalchemy.", - poolclass=pool.NullPool, # 마이그레이션 시에는 커넥션 풀을 사용하지 않음 - ), - ) - # 실제 DB 접속 후 'do_run_migrations' 함수를 동기적으로 실행(run_sync) - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - # 작업 완료 후 엔진 자원 해제 - await connectable.dispose() - -# --- 실행부 --- -if context.is_offline_mode(): - run_migrations_offline() -else: - # 비동기 함수인 run_migrations_online을 이벤트 루프에서 실행 +import asyncio +from logging.config import fileConfig + +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlmodel import SQLModel +from sqlalchemy.engine import Connection + +# ------------------------------------------------------------------- +# 1. 모델 및 설정 로드 (Alembic이 "무엇을" "어디에" 적용할지 정의) +# ------------------------------------------------------------------- + +# 설계도(Model) 로드: Alembic이 테이블 구조를 파악할 수 있게 메모리에 올림 +from appserver.apps.account import models # noqa +from appserver.apps.calendar import models # noqa +# 연결 주소: 프로젝트 설정 파일에서 실제 DB 주소(DSN)를 가져옴 +from appserver.db import DSN +# Alembic 설정 객체: alembic.ini 파일의 내용에 접근할 수 있게 해줌 +from alembic import context + +config = context.config + +# 로깅 설정: alembic.ini의 설정을 바탕으로 로그를 출력합니다 (콘솔에 찍히는 내용들). +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# target_metadata: Alembic이 추적할 "기준" 설계도입니다. +target_metadata = SQLModel.metadata + +def run_migrations_offline() -> None: + """오프라인 모드: 실제 DB 연결 없이 SQL 스크립트 파일만 생성할 때 생성""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url or DSN, # ini 파일에 주소가 없으면 우리 프로젝트의 DSN 사용 + target_metadata=target_metadata, + literal_binds=True, # SQL을 텍스트 형태로 출력 + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """온라인 모드: 실제 DB에 접속하여 테이블을 생성하거나 수정할 때 실행 (비동기 방식)""" + configuration = config.get_section(config.config_ini_section, {}) + + # 실제 DB 주소 주입: ini 설정보다 코드상의 DSN을 우선시함 + configuration["sqlalchemy.url"] = DSN + + # 비동기 엔진 생성: SQLAlchemy 설정을 바탕으로 DB 통로를 개설 + connectable = AsyncEngine( + engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, # 마이그레이션 시에는 커넥션 풀을 사용하지 않음 + ), + ) + # 실제 DB 접속 후 'do_run_migrations' 함수를 동기적으로 실행(run_sync) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + # 작업 완료 후 엔진 자원 해제 + await connectable.dispose() + +# --- 실행부 --- +if context.is_offline_mode(): + run_migrations_offline() +else: + # 비동기 함수인 run_migrations_online을 이벤트 루프에서 실행 asyncio.run(run_migrations_online()) \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako index d75eb24..fe620e8 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -1,32 +1,32 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -import sqlalchemy_utc -import sqlmodel.sql.sqltypes -from sqlmodel import Text - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +import sqlalchemy_utc +import sqlmodel.sql.sqltypes +from sqlmodel import Text + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/310ac765367e_initialization.py b/alembic/versions/310ac765367e_initialization.py index fc004f0..5697526 100644 --- a/alembic/versions/310ac765367e_initialization.py +++ b/alembic/versions/310ac765367e_initialization.py @@ -1,99 +1,99 @@ -"""initialization - -Revision ID: 310ac765367e -Revises: -Create Date: 2025-12-28 01:09:54.943661 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -import sqlalchemy_utc -import sqlmodel.sql.sqltypes -from sqlmodel import Text - -# revision identifiers, used by Alembic. -revision: str = '310ac765367e' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), - sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), - sa.Column('password', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), - sa.Column('is_host', sa.Boolean(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email', name='uq_email'), - sa.UniqueConstraint('username') - ) - op.create_table('calendars', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('topics', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), - sa.Column('description', sa.Text(), nullable=False), - sa.Column('google_calendar_id', sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('host_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['host_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('host_id') - ) - op.create_table('oauth_accounts', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False), - sa.Column('provider_account_id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('provider', 'provider_account_id', name='uq_provider_provider_account_id') - ) - op.create_table('time_slots', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('start_time', sa.Time(), nullable=False), - sa.Column('end_time', sa.Time(), nullable=False), - sa.Column('weekdays', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), - sa.Column('calendar_id', sa.Integer(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['calendar_id'], ['calendars.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('bookings', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('when', sa.Date(), nullable=False), - sa.Column('topic', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('description', sa.Text(), nullable=False), - sa.Column('time_slot_id', sa.Integer(), nullable=False), - sa.Column('guest_id', sa.Integer(), nullable=False), - sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['guest_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['time_slot_id'], ['time_slots.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('bookings') - op.drop_table('time_slots') - op.drop_table('oauth_accounts') - op.drop_table('calendars') - op.drop_table('users') - # ### end Alembic commands ### +"""initialization + +Revision ID: 310ac765367e +Revises: +Create Date: 2025-12-28 01:09:54.943661 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +import sqlalchemy_utc +import sqlmodel.sql.sqltypes +from sqlmodel import Text + +# revision identifiers, used by Alembic. +revision: str = '310ac765367e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('display_name', sqlmodel.sql.sqltypes.AutoString(length=40), nullable=False), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('is_host', sa.Boolean(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email', name='uq_email'), + sa.UniqueConstraint('username') + ) + op.create_table('calendars', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('topics', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('google_calendar_id', sqlmodel.sql.sqltypes.AutoString(length=1024), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('host_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['host_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('host_id') + ) + op.create_table('oauth_accounts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False), + sa.Column('provider_account_id', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('provider', 'provider_account_id', name='uq_provider_provider_account_id') + ) + op.create_table('time_slots', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('start_time', sa.Time(), nullable=False), + sa.Column('end_time', sa.Time(), nullable=False), + sa.Column('weekdays', sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('calendar_id', sa.Integer(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['calendar_id'], ['calendars.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('bookings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('when', sa.Date(), nullable=False), + sa.Column('topic', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('time_slot_id', sa.Integer(), nullable=False), + sa.Column('guest_id', sa.Integer(), nullable=False), + sa.Column('created_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sqlalchemy_utc.sqltypes.UtcDateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['guest_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['time_slot_id'], ['time_slots.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('bookings') + op.drop_table('time_slots') + op.drop_table('oauth_accounts') + op.drop_table('calendars') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/appserver/app.py b/appserver/app.py index 78d82ef..65220fc 100644 --- a/appserver/app.py +++ b/appserver/app.py @@ -1,9 +1,9 @@ -from fastapi import FastAPI -from .apps.account.endpoints import router as account_router - -app = FastAPI() - -def include_routers(_app: FastAPI): - _app.include_router(account_router) - +from fastapi import FastAPI +from .apps.account.endpoints import router as account_router + +app = FastAPI() + +def include_routers(_app: FastAPI): + _app.include_router(account_router) + include_routers(app) \ No newline at end of file diff --git a/appserver/apps/account/constants.py b/appserver/apps/account/constants.py index 9920459..c1f7de2 100644 --- a/appserver/apps/account/constants.py +++ b/appserver/apps/account/constants.py @@ -1,2 +1,2 @@ -# 쿠키 이름을 하드 코딩하지 않고 관리하기 +# 쿠키 이름을 하드 코딩하지 않고 관리하기 AUTH_TOKEN_COOKIE_NAME = "auth_token" \ No newline at end of file diff --git a/appserver/apps/account/deps.py b/appserver/apps/account/deps.py index b01ba87..8ec83ca 100644 --- a/appserver/apps/account/deps.py +++ b/appserver/apps/account/deps.py @@ -1,80 +1,80 @@ -from datetime import datetime, timezone, timedelta -from typing import Annotated - -from fastapi import Cookie, Depends -from sqlmodel import select - -from appserver.db import DbSessionDep -from .exceptions import ( - ExpiredTokenError, - InvalidTokenError, - UserNotFoundError, -) -from .models import User -from .utils import ACCESS_TOKEN_EXPIRE_MINUTES, decode_token -from sqlalchemy.ext.asyncio import AsyncSession - -# get_user 함수 - 토큰으로 사용자 조회하는 공통 로직 +from datetime import datetime, timezone, timedelta +from typing import Annotated + +from fastapi import Cookie, Depends +from sqlmodel import select + +from appserver.db import DbSessionDep +from .exceptions import ( + ExpiredTokenError, + InvalidTokenError, + UserNotFoundError, +) +from .models import User +from .utils import ACCESS_TOKEN_EXPIRE_MINUTES, decode_token +from sqlalchemy.ext.asyncio import AsyncSession + +# get_user 함수 - 토큰으로 사용자 조회하는 공통 로직 async def get_user(auth_token: str | None, db_session: AsyncSession) -> User | None: - if not auth_token: - return None - try: - decoded = decode_token(auth_token) - except Exception as e: - raise InvalidTokenError() from e - - expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) - now = datetime.now(timezone.utc) - if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: - raise ExpiredTokenError() from e - - stmt = select(User).where(User.username == decoded["sub"]) - result = await db_session.execute(stmt) - - return result.scalar_one_or_none() - + if not auth_token: + return None + try: + decoded = decode_token(auth_token) + except Exception as e: + raise InvalidTokenError() from e + + expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) + now = datetime.now(timezone.utc) + if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: + raise ExpiredTokenError() from e + + stmt = select(User).where(User.username == decoded["sub"]) + result = await db_session.execute(stmt) + + return result.scalar_one_or_none() + async def get_current_user( auth_token: Annotated[str, Cookie()], db_session: DbSessionDep, ): - # # 쿠키에 토큰이 없으면 인증 실패 - # if auth_token is None: - # raise InvalidTokenError() - - # # 토큰 디코딩 (실패 시 인증 오류로 변환) - # try: - # decoded = decode_token(auth_token) - # except Exception as e: - # raise InvalidTokenError() from e - - # # 토큰의 만료 여부 확인 - # expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) - # now = datetime.now(timezone.utc) - # if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: - # raise ExpiredTokenError() - - # # 토큰의 subject(username)으로 사용자 조회 + # # 쿠키에 토큰이 없으면 인증 실패 + # if auth_token is None: + # raise InvalidTokenError() + + # # 토큰 디코딩 (실패 시 인증 오류로 변환) + # try: + # decoded = decode_token(auth_token) + # except Exception as e: + # raise InvalidTokenError() from e + + # # 토큰의 만료 여부 확인 + # expires_at = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) + # now = datetime.now(timezone.utc) + # if now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) < expires_at: + # raise ExpiredTokenError() + + # # 토큰의 subject(username)으로 사용자 조회 # stmt = select(User).where(User.username == decoded["sub"]) # result = await db_session.execute(stmt) # user = result.scalar_one_or_none() user = await get_user(auth_token, db_session) if user is None: raise UserNotFoundError() - - return user - -# 의존성 주입용 타입 별칭 -CurrentUserDep = Annotated[User, Depends(get_current_user)] - -# get_current_user_optional 함수 - 선택적 인증 의존성 -async def get_current_user_optional( - db_session: DbSessionDep, - auth_token: Annotated[str | None, Cookie()] = None, -): - user = await get_user(auth_token, db_session) - return user - -# CurrentUserOptionalDep 타입 별칭 -CurrentUserOptionalDep = Annotated[User | None, Depends(get_current_user_optional)] - - + + return user + +# 의존성 주입용 타입 별칭 +CurrentUserDep = Annotated[User, Depends(get_current_user)] + +# get_current_user_optional 함수 - 선택적 인증 의존성 +async def get_current_user_optional( + db_session: DbSessionDep, + auth_token: Annotated[str | None, Cookie()] = None, +): + user = await get_user(auth_token, db_session) + return user + +# CurrentUserOptionalDep 타입 별칭 +CurrentUserOptionalDep = Annotated[User | None, Depends(get_current_user_optional)] + + diff --git a/appserver/apps/account/endpoints.py b/appserver/apps/account/endpoints.py index 0283970..4241612 100644 --- a/appserver/apps/account/endpoints.py +++ b/appserver/apps/account/endpoints.py @@ -1,233 +1,233 @@ -from datetime import datetime, timezone, timedelta - -from fastapi import APIRouter, HTTPException, status -from fastapi.responses import JSONResponse -from sqlalchemy.exc import IntegrityError -from sqlmodel import func, select, update, delete - -from appserver.db import DbSessionDep -from .constants import AUTH_TOKEN_COOKIE_NAME -from .deps import CurrentUserDep -from .exceptions import ( - DuplicatedEmailError, - DuplicatedUsernameError, - PasswordMismatchError, - UserNotFoundError, -) -from .models import User -from .schemas import ( - LoginPayload, - SignupPayload, - UserDetailOut, - UserOut, -) -from .utils import ( - ACCESS_TOKEN_EXPIRE_MINUTES, - create_access_token, - verify_password, -) -from .schemas import UpdateUserPayload - - -# /account 경로로 시작하는 API 그룹 생성 -router = APIRouter(prefix="/account") - -@router.get("/users/{username}") -async def user_detail(username: str, session: DbSessionDep) -> User: - """ - 데이터베이스에서 특정 사용자 정보를 조회합니다. - - 주의: 현재 dsn이 함수 내부에 있어 매 요청마다 엔진을 생성합니다. - 실제 서비스에서는 글로벌하게 선언된 엔진을 사용하는 것이 성능상 유리합니다. - """ - # [Tip] 경로 이슈를 방지하려면 절대 경로를 쓰거나 환경 변수를 활용하세요. - stmt = select(User).where(User.username == username) - result = await session.execute(stmt) - user = result.scalar_one_or_none() - - if user is not None: - return user - - # DB에 해당 조건의 유저가 없는 경우 - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" - ) - -# ============ 회원가입 API ============ -# response_model=UserOut: 응답 시 UserOut 스키마로 필터링 (password 등 민감정보 제외) -# status_code=201: 리소스 생성 성공 시 HTTP 201 반환 -@router.post("/signup", status_code=status.HTTP_201_CREATED, response_model=UserOut) -async def signup(payload: SignupPayload, session: DbSessionDep) -> User: - """ - 회원가입 엔드포인트. - - 처리 순서: - 1. 입력 검증 (SignupPayload에서 자동 처리) - 2. username 중복 체크 - 3. email 중복 체크 - 4. User 모델로 변환 및 DB 저장 - 5. UserOut 형태로 응답 (민감정보 제외) - """ - # ========== 1. username 중복 체크 ========== - # func.count(): SQL의 COUNT(*) 함수 - # select_from(User): User 테이블에서 조회 - # where(): 조건 필터링 (SQL의 WHERE절) - stmt = select(func.count()).select_from(User).where(User.username == payload.username) - result = await session.execute(stmt) # 비동기로 쿼리 실행 - count = result.scalar_one() # 단일 값(숫자) 추출 - if count > 0: - raise DuplicatedUsernameError() # 커스텀 예외 발생 - - # ========== 2. email 중복 체크 ========== - stmt = select(func.count()).select_from(User).where(User.email == payload.email) - result = await session.execute(stmt) - count = result.scalar_one() - if count > 0: - raise DuplicatedEmailError() - - # ========== 3. 데이터 변환 및 저장 ========== - # payload.model_dump(): SignupPayload 객체 → dict 변환 - # User.model_validate(): dict → User 모델 변환 (검증 포함) - # from_attributes=True: 객체의 속성에서도 값을 가져올 수 있게 함 - user = User.model_validate(payload.model_dump(), from_attributes=True) - session.add(user) # 세션에 추가 (아직 DB에 저장 안 됨) - - try: - # commit(): 세션의 모든 변경사항을 DB에 실제로 반영 - # 이 시점에 INSERT 쿼리가 실행됨 - await session.commit() - except IntegrityError as e: - # Race Condition 대비: 중복 체크와 저장 사이에 다른 요청이 끼어들 수 있음 - # DB의 UNIQUE 제약조건 위반 시 IntegrityError 발생 - if "email" in str(e.orig): - raise DuplicatedEmailError() - else: - raise DuplicatedUsernameError() - - # ========== 4. 응답 반환 ========== - # User 객체를 반환하지만, response_model=UserOut 때문에 - # 실제로는 UserOut 형태로 필터링되어 응답됨 (password 제외) - return user - -@router.post("/login", status_code=status.HTTP_200_OK) -async def login(payload: LoginPayload, session: DbSessionDep) -> JSONResponse: - """ - 로그인 요청을 수신하고, 자격이 확인되면 토큰과 쿠키를 반환합니다. - - 1. 사용자 조회 - 2. 비밀번호 검증 - 3. JWT 액세스 토큰 생성 (신분증 발급) - 4. 쿠키에 토큰 설정 (신분증을 지갑에 넣기) - """ - # ========== 1. 사용자 조회 ========== - stmt = select(User).where(User.username == payload.username) - result = await session.execute(stmt) - # scalar_one_or_none(): - # - 결과가 1개면 해당 객체 반환 - # - 없으면 None 반환 - # - 2개 이상이면 에러 발생 (Unique 조건이 있어서 2개일 수는 없음) - user = result.scalar_one_or_none() - - if user is None: - raise UserNotFoundError() - - # ========== 2. 비밀번호 검증 ========== - # 입력한 평문 비밀번호(payload.password)와 DB에 저장된 해시(user.hashed_password) 비교 - # verify_password 내부에서 Argon2/Bcrypt 등을 사용하여 검증함 - is_valid = verify_password(payload.password, user.hashed_password) - if not is_valid: - raise PasswordMismatchError() - - # ========== 3. 토큰 생성 (신분증 발급) ========== - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - - # payload(내용물)에는 식별 가능한 최소한의 정보를 담습니다. - # 비밀번호 같은 민감 정보는 절대 담으면 안 됩니다. - access_token = create_access_token( - data={ - "sub": user.username, # sub (Subject): 토큰의 주인 (보통 ID 사용) - "displayname": user.display_name, - "is_host": user.is_host, - }, - expires_delta=access_token_expires, - ) - - # 응답 본문(Body)에 담을 데이터 구성 - response_data = { - "access_token": access_token, - "token_type": "bearer", - "user": user.model_dump(mode="json", exclude={"hashed_password", "email"}) - } - - # ========== 4. 쿠키 설정 및 응답 (지갑에 넣기) ========== - # JSONResponse 객체를 직접 생성해야 쿠키(Header)를 조작할 수 있습니다. - res = JSONResponse(response_data, status_code=status.HTTP_200_OK) - - # 현재 시간 (쿠키 만료 계산용) - now = datetime.now(timezone.utc) - - # set_cookie: 브라우저에게 "이 데이터를 쿠키 저장소에 저장해!"라고 명령 - res.set_cookie( - key=AUTH_TOKEN_COOKIE_NAME, # 쿠키 이름 (예: "access_token") - value=access_token, # 쿠키 값 (JWT 문자열) - - # 만료 시간 설정 (이 시간이 지나면 브라우저가 알아서 쿠키를 삭제함) - expires=now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), - - # [보안 중요] httponly=True: - # 자바스크립트(document.cookie)로 이 쿠키에 접근할 수 없게 막음. - # XSS(교차 사이트 스크립팅) 공격 시 토큰 탈취를 방지하는 핵심 옵션. - httponly=True, - - # [보안 중요] secure=True: - # HTTPS(암호화된 연결)인 경우에만 쿠키를 서버로 전송함. - # 네트워크 스니핑(패킷 가로채기)으로부터 토큰을 보호함. (로컬 개발에선 http여도 동작하게 설정 필요할 수 있음) - secure=True, - - # [보안 중요] samesite="strict": - # 다른 사이트에서 우리 사이트로 요청을 보낼 때 쿠키를 전송하지 않음. - # CSRF(사이트 간 요청 위조) 공격을 방지함. - samesite="strict" - ) - return res - -@router.get("/@me", response_model=UserDetailOut) -async def me(user: CurrentUserDep) -> User: - """ - 현재 로그인한 사용자의 정보를 반환합니다. - - Args: - user (CurrentUserDep): - FastAPI의 Dependency Injection 시스템이 동작합니다. - 1. 요청의 쿠키에서 토큰을 꺼냅니다. - 2. 토큰을 검증하고 디코딩하여 username을 얻습니다. - 3. DB에서 해당 username을 조회하여 User 객체를 만들어 여기에 주입해줍니다. - (이 모든 과정은 deps.py의 get_current_user 함수에서 처리됩니다) - """ - return user - -@router.patch("/@me", response_model=UserDetailOut) -async def update_user( - user: CurrentUserDep, - payload: UpdateUserPayload, - session: DbSessionDep -) -> User: - updated_data = payload.model_dump(exclude_unset=True, exclude={"password", "password_again"}) - stmt = update(User).where(User.username == user.username).values(**updated_data) - await session.execute(stmt) - await session.commit() - return user - -@router.delete("/logout", status_code=status.HTTP_200_OK) -async def logout(user: CurrentUserDep) -> JSONResponse: - res = JSONResponse({}) - res.delete_cookie(AUTH_TOKEN_COOKIE_NAME) - return res - -@router.delete("/unregister", status_code=status.HTTP_204_NO_CONTENT) -async def unregister(user: CurrentUserDep, session:DbSessionDep) -> None: - stmt = delete(User).where(User.username == user.username) - await session.execute(stmt) - await session.commit() - return None +from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, HTTPException, status +from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError +from sqlmodel import func, select, update, delete + +from appserver.db import DbSessionDep +from .constants import AUTH_TOKEN_COOKIE_NAME +from .deps import CurrentUserDep +from .exceptions import ( + DuplicatedEmailError, + DuplicatedUsernameError, + PasswordMismatchError, + UserNotFoundError, +) +from .models import User +from .schemas import ( + LoginPayload, + SignupPayload, + UserDetailOut, + UserOut, +) +from .utils import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + create_access_token, + verify_password, +) +from .schemas import UpdateUserPayload + + +# /account 경로로 시작하는 API 그룹 생성 +router = APIRouter(prefix="/account") + +@router.get("/users/{username}") +async def user_detail(username: str, session: DbSessionDep) -> User: + """ + 데이터베이스에서 특정 사용자 정보를 조회합니다. + + 주의: 현재 dsn이 함수 내부에 있어 매 요청마다 엔진을 생성합니다. + 실제 서비스에서는 글로벌하게 선언된 엔진을 사용하는 것이 성능상 유리합니다. + """ + # [Tip] 경로 이슈를 방지하려면 절대 경로를 쓰거나 환경 변수를 활용하세요. + stmt = select(User).where(User.username == username) + result = await session.execute(stmt) + user = result.scalar_one_or_none() + + if user is not None: + return user + + # DB에 해당 조건의 유저가 없는 경우 + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + +# ============ 회원가입 API ============ +# response_model=UserOut: 응답 시 UserOut 스키마로 필터링 (password 등 민감정보 제외) +# status_code=201: 리소스 생성 성공 시 HTTP 201 반환 +@router.post("/signup", status_code=status.HTTP_201_CREATED, response_model=UserOut) +async def signup(payload: SignupPayload, session: DbSessionDep) -> User: + """ + 회원가입 엔드포인트. + + 처리 순서: + 1. 입력 검증 (SignupPayload에서 자동 처리) + 2. username 중복 체크 + 3. email 중복 체크 + 4. User 모델로 변환 및 DB 저장 + 5. UserOut 형태로 응답 (민감정보 제외) + """ + # ========== 1. username 중복 체크 ========== + # func.count(): SQL의 COUNT(*) 함수 + # select_from(User): User 테이블에서 조회 + # where(): 조건 필터링 (SQL의 WHERE절) + stmt = select(func.count()).select_from(User).where(User.username == payload.username) + result = await session.execute(stmt) # 비동기로 쿼리 실행 + count = result.scalar_one() # 단일 값(숫자) 추출 + if count > 0: + raise DuplicatedUsernameError() # 커스텀 예외 발생 + + # ========== 2. email 중복 체크 ========== + stmt = select(func.count()).select_from(User).where(User.email == payload.email) + result = await session.execute(stmt) + count = result.scalar_one() + if count > 0: + raise DuplicatedEmailError() + + # ========== 3. 데이터 변환 및 저장 ========== + # payload.model_dump(): SignupPayload 객체 → dict 변환 + # User.model_validate(): dict → User 모델 변환 (검증 포함) + # from_attributes=True: 객체의 속성에서도 값을 가져올 수 있게 함 + user = User.model_validate(payload.model_dump(), from_attributes=True) + session.add(user) # 세션에 추가 (아직 DB에 저장 안 됨) + + try: + # commit(): 세션의 모든 변경사항을 DB에 실제로 반영 + # 이 시점에 INSERT 쿼리가 실행됨 + await session.commit() + except IntegrityError as e: + # Race Condition 대비: 중복 체크와 저장 사이에 다른 요청이 끼어들 수 있음 + # DB의 UNIQUE 제약조건 위반 시 IntegrityError 발생 + if "email" in str(e.orig): + raise DuplicatedEmailError() + else: + raise DuplicatedUsernameError() + + # ========== 4. 응답 반환 ========== + # User 객체를 반환하지만, response_model=UserOut 때문에 + # 실제로는 UserOut 형태로 필터링되어 응답됨 (password 제외) + return user + +@router.post("/login", status_code=status.HTTP_200_OK) +async def login(payload: LoginPayload, session: DbSessionDep) -> JSONResponse: + """ + 로그인 요청을 수신하고, 자격이 확인되면 토큰과 쿠키를 반환합니다. + + 1. 사용자 조회 + 2. 비밀번호 검증 + 3. JWT 액세스 토큰 생성 (신분증 발급) + 4. 쿠키에 토큰 설정 (신분증을 지갑에 넣기) + """ + # ========== 1. 사용자 조회 ========== + stmt = select(User).where(User.username == payload.username) + result = await session.execute(stmt) + # scalar_one_or_none(): + # - 결과가 1개면 해당 객체 반환 + # - 없으면 None 반환 + # - 2개 이상이면 에러 발생 (Unique 조건이 있어서 2개일 수는 없음) + user = result.scalar_one_or_none() + + if user is None: + raise UserNotFoundError() + + # ========== 2. 비밀번호 검증 ========== + # 입력한 평문 비밀번호(payload.password)와 DB에 저장된 해시(user.hashed_password) 비교 + # verify_password 내부에서 Argon2/Bcrypt 등을 사용하여 검증함 + is_valid = verify_password(payload.password, user.hashed_password) + if not is_valid: + raise PasswordMismatchError() + + # ========== 3. 토큰 생성 (신분증 발급) ========== + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + # payload(내용물)에는 식별 가능한 최소한의 정보를 담습니다. + # 비밀번호 같은 민감 정보는 절대 담으면 안 됩니다. + access_token = create_access_token( + data={ + "sub": user.username, # sub (Subject): 토큰의 주인 (보통 ID 사용) + "displayname": user.display_name, + "is_host": user.is_host, + }, + expires_delta=access_token_expires, + ) + + # 응답 본문(Body)에 담을 데이터 구성 + response_data = { + "access_token": access_token, + "token_type": "bearer", + "user": user.model_dump(mode="json", exclude={"hashed_password", "email"}) + } + + # ========== 4. 쿠키 설정 및 응답 (지갑에 넣기) ========== + # JSONResponse 객체를 직접 생성해야 쿠키(Header)를 조작할 수 있습니다. + res = JSONResponse(response_data, status_code=status.HTTP_200_OK) + + # 현재 시간 (쿠키 만료 계산용) + now = datetime.now(timezone.utc) + + # set_cookie: 브라우저에게 "이 데이터를 쿠키 저장소에 저장해!"라고 명령 + res.set_cookie( + key=AUTH_TOKEN_COOKIE_NAME, # 쿠키 이름 (예: "access_token") + value=access_token, # 쿠키 값 (JWT 문자열) + + # 만료 시간 설정 (이 시간이 지나면 브라우저가 알아서 쿠키를 삭제함) + expires=now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + + # [보안 중요] httponly=True: + # 자바스크립트(document.cookie)로 이 쿠키에 접근할 수 없게 막음. + # XSS(교차 사이트 스크립팅) 공격 시 토큰 탈취를 방지하는 핵심 옵션. + httponly=True, + + # [보안 중요] secure=True: + # HTTPS(암호화된 연결)인 경우에만 쿠키를 서버로 전송함. + # 네트워크 스니핑(패킷 가로채기)으로부터 토큰을 보호함. (로컬 개발에선 http여도 동작하게 설정 필요할 수 있음) + secure=True, + + # [보안 중요] samesite="strict": + # 다른 사이트에서 우리 사이트로 요청을 보낼 때 쿠키를 전송하지 않음. + # CSRF(사이트 간 요청 위조) 공격을 방지함. + samesite="strict" + ) + return res + +@router.get("/@me", response_model=UserDetailOut) +async def me(user: CurrentUserDep) -> User: + """ + 현재 로그인한 사용자의 정보를 반환합니다. + + Args: + user (CurrentUserDep): + FastAPI의 Dependency Injection 시스템이 동작합니다. + 1. 요청의 쿠키에서 토큰을 꺼냅니다. + 2. 토큰을 검증하고 디코딩하여 username을 얻습니다. + 3. DB에서 해당 username을 조회하여 User 객체를 만들어 여기에 주입해줍니다. + (이 모든 과정은 deps.py의 get_current_user 함수에서 처리됩니다) + """ + return user + +@router.patch("/@me", response_model=UserDetailOut) +async def update_user( + user: CurrentUserDep, + payload: UpdateUserPayload, + session: DbSessionDep +) -> User: + updated_data = payload.model_dump(exclude_unset=True, exclude={"password", "password_again"}) + stmt = update(User).where(User.username == user.username).values(**updated_data) + await session.execute(stmt) + await session.commit() + return user + +@router.delete("/logout", status_code=status.HTTP_200_OK) +async def logout(user: CurrentUserDep) -> JSONResponse: + res = JSONResponse({}) + res.delete_cookie(AUTH_TOKEN_COOKIE_NAME) + return res + +@router.delete("/unregister", status_code=status.HTTP_204_NO_CONTENT) +async def unregister(user: CurrentUserDep, session:DbSessionDep) -> None: + stmt = delete(User).where(User.username == user.username) + await session.execute(stmt) + await session.commit() + return None diff --git a/appserver/apps/account/exceptions.py b/appserver/apps/account/exceptions.py index 12bf1da..b48ce87 100644 --- a/appserver/apps/account/exceptions.py +++ b/appserver/apps/account/exceptions.py @@ -1,47 +1,47 @@ -from fastapi import HTTPException, status - -class DuplicatedUsernameError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="중복된 계정 ID입니다.", - ) - -class DuplicatedEmailError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="중복된 이메일입니다.", - ) - -class UserNotFoundError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", - ) - -class PasswordMismatchError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Password mismatch", - ) - -class InvalidTokenError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="유효하지 않은 인증 토큰입니다", - headers={"WWW-Authenticate": "Bearer"} - ) - -class ExpiredTokenError(HTTPException): - def __init__(self): - super().__init__( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="인증 토큰이 만료되었습니다", - headers={"WWW-Authenticate": "Bearer"}, - ) - +from fastapi import HTTPException, status + +class DuplicatedUsernameError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="중복된 계정 ID입니다.", + ) + +class DuplicatedEmailError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="중복된 이메일입니다.", + ) + +class UserNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + +class PasswordMismatchError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Password mismatch", + ) + +class InvalidTokenError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="유효하지 않은 인증 토큰입니다", + headers={"WWW-Authenticate": "Bearer"} + ) + +class ExpiredTokenError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증 토큰이 만료되었습니다", + headers={"WWW-Authenticate": "Bearer"}, + ) + \ No newline at end of file diff --git a/appserver/apps/account/models.py b/appserver/apps/account/models.py index 027fad5..768781e 100644 --- a/appserver/apps/account/models.py +++ b/appserver/apps/account/models.py @@ -1,130 +1,130 @@ -from datetime import datetime, timezone -from sqlmodel import SQLModel, Field, Relationship, func, Column, AutoString -from pydantic import EmailStr, AwareDatetime # 이메일 형식이 유효한지(@ 포함 등) 자동으로 검사해주는 도구 -from sqlalchemy import UniqueConstraint # 특정 컬럼의 값이 중복되지 않도록 DB 레벨에서 강제하는 -from sqlalchemy_utc import UtcDateTime -from typing import TYPE_CHECKING, Union -import random -import string -from pydantic import model_validator - - -if TYPE_CHECKING: - from appserver.apps.calendar.models import Calendar, Booking - -class User(SQLModel, table=True): - __tablename__ = "users" # 데이터베이스 테이블의 이름 지정 # table=True 인자를 지정한 경유 유효 - - # DB 제약 조건 설정: "email" 컬럼에 중복된 값이 들어올 수 없도록 이름(uq_email)을 붙여 설정함 - __table_args__ = ( - UniqueConstraint("email", name="uq_email"), - ) - - # ID: 기본키(PK). 처음 객체 생성시 None이지만 DB 저장 시 자동 생성됨 - id: int = Field(default=None, primary_key=True) - - # username: 고유해야 하며, 최대 40자 제한, description은 Swagger 문성에 설명으로 표시됨 - username: str = Field(min_length=4,unique=True, max_length=40, description="사용자 계정 ID") - - # email: Pydantic의 EamilStr을 사용해 문자열이 아닌 진짜 이메일 형식인지 검증함 - email: EmailStr = Field(max_length=128, description="사용자 이메일") - - # display_name: 서비스에서 보여질 별명, 길이를 제한하여 DB 공간 효율 증대 - display_name: str = Field(min_length=4, max_length=40, description="사용자 표시 이름") - - # hashed_password: 실제 비밀번호가 저장될 곳, 나중에 해싱(암호화)된 문자열이 저장될 예정 - hashed_password: str = Field(min_length=4, max_length=128, description="사용자 비밀번호") - - # password: 실제 비밀번호가 저장될 곳, 나중에 해싱(암호화)된 문자열이 저장될 예정 - password: str = Field(min_length=4, max_length=128, description="사용자 비밀번호") - # is_host: 호스트/게스트 구분용, 기본값 False(게스트)로 설정 - - is_host: bool = Field(default=False, description="사용자가 호스트인지 여부") - # 생성/ 수정 시간: 데이터의 이력을 추적하기 위한 필수 필드 - created_at: AwareDatetime = Field( - default=None, # 파이썬에서 처리하는 default - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), # 데이터베이스에서 처리하는 server_default - }, # func는 SQLAlchemy에서 제공하는 객체로, 각 데이터베이스에서 현재 일시 값을 만드는 함수 - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - # ORM의 객체 데이터가 갱신될 때 호출될 파이썬 객체를 받음 - }, - ) - - oauth_accounts: list["OAuthAccount"] = Relationship(back_populates="user") - # list["OAuthAccount"]: 한 명의 여러 개의 소셜 계정을 가질 수 있는 1:N(일대 다) 관계를 의미 - # 따옴표(" "): OAuthAccount 클래스가 아래의 정의되어 있어, 파이썬이 미리 알 수있게 '문자열'로 타입을 명시 한 것 (Forward Refrence) - # back_populates: 양방향 연결 설정. OAuthAccount 쪽에서도 .user를 통해 이 사용자를 바로 조회할 수 있게 함. - - calendar: Union["Calendar", None] = Relationship( - back_populates="host", - sa_relationship_kwargs={"uselist": False, "single_parent": True}, - ) - bookings: list["Booking"] = Relationship(back_populates="guest") - - @model_validator(mode="before") # 데이터 검증 전에 실행, Pydantic 모델 생성 전에 실행 - @classmethod - def generate_display_name(cls, data: dict): - if not data.get("display_name"): - data["display_name"] = "".join( - random.choices( - string.ascii_letters + string.digits, - k=8, - ) - ) - return data - # @model_validator(mode="after") - # pydantic이 데이터를 검증하고 모델 객체를 만들기 직전에 이 함수를 실행하라는 뜻 - # mode = "after" 일 때는 입력받은 데이터가 아직 딕셔너리 형태입니다. 데이터 타입을 맞추거나, - # 빠진 값을 채워넣는 전처리 단계에서 주로 사용 - # 함수가 특정 인스턴스가 아니라 클래스 자체에 속함 - -class OAuthAccount(SQLModel, table=True): - __tablename__ = "oauth_accounts" - __table_args__ = ( - UniqueConstraint( - "provider", - "provider_account_id", - name="uq_provider_provider_account_id", - ), # 쉼표(,) 추가 튜플 - ) - # 한 명의 사용자가 동일한 제공자(예: 카카오)의 동일한 게정으로 - # 중복 가입되는 것을 DB 레벨에서 원천 봉쇄함 (보안 및 데이터 무결성) - - id: int = Field(default=None, primary_key=True) - provider: str = Field(max_length=10, description="OAuth 제공자") - # google, kakao, github 등 소셜 로그인 서비스 이름을 저장 - provider_account_id: str = Field(max_length=128, description="OAuth 제공자 계정 ID") - # 외부 서비스에서 우리에게 넘겨주는 해당 사용자의 고유 식별 번호 - user_id: int = Field(foreign_key="users.id") - # users 테이블의 id 컬럼을 참조하는 외래키 - # 이 계정이 어떤 유저의 소유인지 물리적으로 연결 - - user: User = Relationship(back_populates="oauth_accounts") - - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, # DB 저장 시 타임존 정보를 포함하여 항상 UTC 기준으로 저장되도록 강제. - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), # 데이터가 수정될 떄마다 파이썬이 실행 시점의 UTC 시간을 계산해서 넣어줌. - }, +from datetime import datetime, timezone +from sqlmodel import SQLModel, Field, Relationship, func, Column, AutoString +from pydantic import EmailStr, AwareDatetime # 이메일 형식이 유효한지(@ 포함 등) 자동으로 검사해주는 도구 +from sqlalchemy import UniqueConstraint # 특정 컬럼의 값이 중복되지 않도록 DB 레벨에서 강제하는 +from sqlalchemy_utc import UtcDateTime +from typing import TYPE_CHECKING, Union +import random +import string +from pydantic import model_validator + + +if TYPE_CHECKING: + from appserver.apps.calendar.models import Calendar, Booking + +class User(SQLModel, table=True): + __tablename__ = "users" # 데이터베이스 테이블의 이름 지정 # table=True 인자를 지정한 경유 유효 + + # DB 제약 조건 설정: "email" 컬럼에 중복된 값이 들어올 수 없도록 이름(uq_email)을 붙여 설정함 + __table_args__ = ( + UniqueConstraint("email", name="uq_email"), + ) + + # ID: 기본키(PK). 처음 객체 생성시 None이지만 DB 저장 시 자동 생성됨 + id: int = Field(default=None, primary_key=True) + + # username: 고유해야 하며, 최대 40자 제한, description은 Swagger 문성에 설명으로 표시됨 + username: str = Field(min_length=4,unique=True, max_length=40, description="사용자 계정 ID") + + # email: Pydantic의 EamilStr을 사용해 문자열이 아닌 진짜 이메일 형식인지 검증함 + email: EmailStr = Field(max_length=128, description="사용자 이메일") + + # display_name: 서비스에서 보여질 별명, 길이를 제한하여 DB 공간 효율 증대 + display_name: str = Field(min_length=4, max_length=40, description="사용자 표시 이름") + + # hashed_password: 실제 비밀번호가 저장될 곳, 나중에 해싱(암호화)된 문자열이 저장될 예정 + hashed_password: str = Field(min_length=4, max_length=128, description="사용자 비밀번호") + + # password: 실제 비밀번호가 저장될 곳, 나중에 해싱(암호화)된 문자열이 저장될 예정 + password: str = Field(min_length=4, max_length=128, description="사용자 비밀번호") + # is_host: 호스트/게스트 구분용, 기본값 False(게스트)로 설정 + + is_host: bool = Field(default=False, description="사용자가 호스트인지 여부") + # 생성/ 수정 시간: 데이터의 이력을 추적하기 위한 필수 필드 + created_at: AwareDatetime = Field( + default=None, # 파이썬에서 처리하는 default + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), # 데이터베이스에서 처리하는 server_default + }, # func는 SQLAlchemy에서 제공하는 객체로, 각 데이터베이스에서 현재 일시 값을 만드는 함수 + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + # ORM의 객체 데이터가 갱신될 때 호출될 파이썬 객체를 받음 + }, + ) + + oauth_accounts: list["OAuthAccount"] = Relationship(back_populates="user") + # list["OAuthAccount"]: 한 명의 여러 개의 소셜 계정을 가질 수 있는 1:N(일대 다) 관계를 의미 + # 따옴표(" "): OAuthAccount 클래스가 아래의 정의되어 있어, 파이썬이 미리 알 수있게 '문자열'로 타입을 명시 한 것 (Forward Refrence) + # back_populates: 양방향 연결 설정. OAuthAccount 쪽에서도 .user를 통해 이 사용자를 바로 조회할 수 있게 함. + + calendar: Union["Calendar", None] = Relationship( + back_populates="host", + sa_relationship_kwargs={"uselist": False, "single_parent": True}, + ) + bookings: list["Booking"] = Relationship(back_populates="guest") + + @model_validator(mode="before") # 데이터 검증 전에 실행, Pydantic 모델 생성 전에 실행 + @classmethod + def generate_display_name(cls, data: dict): + if not data.get("display_name"): + data["display_name"] = "".join( + random.choices( + string.ascii_letters + string.digits, + k=8, + ) + ) + return data + # @model_validator(mode="after") + # pydantic이 데이터를 검증하고 모델 객체를 만들기 직전에 이 함수를 실행하라는 뜻 + # mode = "after" 일 때는 입력받은 데이터가 아직 딕셔너리 형태입니다. 데이터 타입을 맞추거나, + # 빠진 값을 채워넣는 전처리 단계에서 주로 사용 + # 함수가 특정 인스턴스가 아니라 클래스 자체에 속함 + +class OAuthAccount(SQLModel, table=True): + __tablename__ = "oauth_accounts" + __table_args__ = ( + UniqueConstraint( + "provider", + "provider_account_id", + name="uq_provider_provider_account_id", + ), # 쉼표(,) 추가 튜플 + ) + # 한 명의 사용자가 동일한 제공자(예: 카카오)의 동일한 게정으로 + # 중복 가입되는 것을 DB 레벨에서 원천 봉쇄함 (보안 및 데이터 무결성) + + id: int = Field(default=None, primary_key=True) + provider: str = Field(max_length=10, description="OAuth 제공자") + # google, kakao, github 등 소셜 로그인 서비스 이름을 저장 + provider_account_id: str = Field(max_length=128, description="OAuth 제공자 계정 ID") + # 외부 서비스에서 우리에게 넘겨주는 해당 사용자의 고유 식별 번호 + user_id: int = Field(foreign_key="users.id") + # users 테이블의 id 컬럼을 참조하는 외래키 + # 이 계정이 어떤 유저의 소유인지 물리적으로 연결 + + user: User = Relationship(back_populates="oauth_accounts") + + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, # DB 저장 시 타임존 정보를 포함하여 항상 UTC 기준으로 저장되도록 강제. + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), # 데이터가 수정될 떄마다 파이썬이 실행 시점의 UTC 시간을 계산해서 넣어줌. + }, ) \ No newline at end of file diff --git a/appserver/apps/account/schemas.py b/appserver/apps/account/schemas.py index 0b046cd..3f6a7c8 100644 --- a/appserver/apps/account/schemas.py +++ b/appserver/apps/account/schemas.py @@ -1,87 +1,87 @@ -import random -import string -from typing_extensions import Self # Python 3.10 호환성 -from pydantic import model_validator, EmailStr, AwareDatetime, computed_field -from sqlmodel import SQLModel, Field -from .utils import hash_password - -# ============ 회원가입 입력 스키마 ============ -# 클라이언트로부터 받는 회원가입 데이터의 형식을 정의 -# Field()로 각 필드의 검증 규칙 설정 -class SignupPayload(SQLModel): - username: str = Field(min_length=4, unique=True, max_length=40, description="사용자 계정 ID") - email: EmailStr = Field(unique=True, max_length=128, description="사용자 이메일") # EmailStr: 이메일 형식 자동 검증 - display_name: str = Field(min_length=4, max_length=40, description="사용자 표시 이름") - password: str = Field(min_length=8, max_length=128, description="사용자 비밀번호") - password_again: str = Field(min_length=8, max_length=128, description="사용자 비밀번호 확인") - - # ========== Validator 1: 비밀번호 일치 검증 ========== - # mode="after": 모든 필드 검증이 끝난 후 실행 (이미 SignupPayload 객체가 생성된 상태) - # Self: 자기 자신의 타입(SignupPayload)을 반환한다는 의미 - @model_validator(mode="after") - def verify_password(self) -> Self: - if self.password != self.password_again: - raise ValueError("Passwords do not match") - return self - - # ========== Validator 2: display_name 자동 생성 ========== - # mode="before": 필드 검증 전에 실행 (아직 dict 상태) - # @classmethod: 인스턴스가 아닌 클래스 자체를 받음 (cls = SignupPayload) - @model_validator(mode="before") - @classmethod - def generate_display_name(cls, data: dict) -> dict: - """display_name이 없으면 랜덤 8자리 문자열 생성.""" - if not data.get("display_name"): - # string.ascii_letters: 'abcd...xyzABC...XYZ' - # string.digits: '0123456789' - # random.choices(pool, k=8): pool에서 8개 랜덤 선택 (중복 허용) - data["display_name"] = "".join( - random.choices( - string.ascii_letters + string.digits, - k=8, - ) - ) - return data - -# ============ 회원가입 응답 스키마 ============ -# API 응답 시 클라이언트에게 보여줄 필드만 정의 -# password, email 등 민감정보는 제외 -class UserOut(SQLModel): - username: str - display_name: str - is_host: bool - -class LoginPayload(SQLModel): - """로그인 API에서 받는 페이로드""" - username: str = Field(min_length=4, max_length=40) - password: str = Field(min_length=8, max_length=128) - -class UserDetailOut(UserOut): - email: EmailStr - created_at: AwareDatetime - updated_at: AwareDatetime - -class UpdateUserPayload(SQLModel): - display_name: str | None = Field(default=None, min_length=4, max_length=40) - email: EmailStr | None = Field(default=None, unique=True, max_length=128) - password: str | None = Field(default=None, min_length=8, max_length=128) - password_again: str | None = Field(default=None, min_length=8, max_length=128) - - @model_validator(mode="after") - def check_all_fields_are_none(self) -> Self: - if not self.model_dump(exclude_none=True): - raise ValueError("최소 하나의 필드는 반드시 제공되어야 합니다.") - return self - - @model_validator(mode="after") - def verify_password(self) -> Self: - if self.password != self.password_again: - raise ValueError("비밀번호가 일치하지 않습니다.") - return self - - @computed_field - @property - def hashed_password(self) -> str | None: - if self.password: - return hash_password(self.password) +import random +import string +from typing_extensions import Self # Python 3.10 호환성 +from pydantic import model_validator, EmailStr, AwareDatetime, computed_field +from sqlmodel import SQLModel, Field +from .utils import hash_password + +# ============ 회원가입 입력 스키마 ============ +# 클라이언트로부터 받는 회원가입 데이터의 형식을 정의 +# Field()로 각 필드의 검증 규칙 설정 +class SignupPayload(SQLModel): + username: str = Field(min_length=4, unique=True, max_length=40, description="사용자 계정 ID") + email: EmailStr = Field(unique=True, max_length=128, description="사용자 이메일") # EmailStr: 이메일 형식 자동 검증 + display_name: str = Field(min_length=4, max_length=40, description="사용자 표시 이름") + password: str = Field(min_length=8, max_length=128, description="사용자 비밀번호") + password_again: str = Field(min_length=8, max_length=128, description="사용자 비밀번호 확인") + + # ========== Validator 1: 비밀번호 일치 검증 ========== + # mode="after": 모든 필드 검증이 끝난 후 실행 (이미 SignupPayload 객체가 생성된 상태) + # Self: 자기 자신의 타입(SignupPayload)을 반환한다는 의미 + @model_validator(mode="after") + def verify_password(self) -> Self: + if self.password != self.password_again: + raise ValueError("Passwords do not match") + return self + + # ========== Validator 2: display_name 자동 생성 ========== + # mode="before": 필드 검증 전에 실행 (아직 dict 상태) + # @classmethod: 인스턴스가 아닌 클래스 자체를 받음 (cls = SignupPayload) + @model_validator(mode="before") + @classmethod + def generate_display_name(cls, data: dict) -> dict: + """display_name이 없으면 랜덤 8자리 문자열 생성.""" + if not data.get("display_name"): + # string.ascii_letters: 'abcd...xyzABC...XYZ' + # string.digits: '0123456789' + # random.choices(pool, k=8): pool에서 8개 랜덤 선택 (중복 허용) + data["display_name"] = "".join( + random.choices( + string.ascii_letters + string.digits, + k=8, + ) + ) + return data + +# ============ 회원가입 응답 스키마 ============ +# API 응답 시 클라이언트에게 보여줄 필드만 정의 +# password, email 등 민감정보는 제외 +class UserOut(SQLModel): + username: str + display_name: str + is_host: bool + +class LoginPayload(SQLModel): + """로그인 API에서 받는 페이로드""" + username: str = Field(min_length=4, max_length=40) + password: str = Field(min_length=8, max_length=128) + +class UserDetailOut(UserOut): + email: EmailStr + created_at: AwareDatetime + updated_at: AwareDatetime + +class UpdateUserPayload(SQLModel): + display_name: str | None = Field(default=None, min_length=4, max_length=40) + email: EmailStr | None = Field(default=None, unique=True, max_length=128) + password: str | None = Field(default=None, min_length=8, max_length=128) + password_again: str | None = Field(default=None, min_length=8, max_length=128) + + @model_validator(mode="after") + def check_all_fields_are_none(self) -> Self: + if not self.model_dump(exclude_none=True): + raise ValueError("최소 하나의 필드는 반드시 제공되어야 합니다.") + return self + + @model_validator(mode="after") + def verify_password(self) -> Self: + if self.password != self.password_again: + raise ValueError("비밀번호가 일치하지 않습니다.") + return self + + @computed_field + @property + def hashed_password(self) -> str | None: + if self.password: + return hash_password(self.password) return None \ No newline at end of file diff --git a/appserver/apps/account/utils.py b/appserver/apps/account/utils.py index 275c843..6924978 100644 --- a/appserver/apps/account/utils.py +++ b/appserver/apps/account/utils.py @@ -1,64 +1,64 @@ -from pwdlib import PasswordHash -from pwdlib.hashers.argon2 import Argon2Hasher -from pwdlib.hashers.bcrypt import BcryptHasher # ⚠️ 오타 주의: bcrypt -from datetime import datetime, timedelta, timezone -from jose import jwt -from typing import Any, Union - -SECRET_KEY = "your-secret-key" -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 30 - -def hash_password(password: str) -> str: - """ - 비밀번호를 해시(암호화)하여 반환 - 사용자가 입력한 평문 비밀번호를 Argon2 알고리즘을 사용하여 - 복호화가 불가능한 해시 문자열로 변환 - - Args: - password(str): 평문 비밀번호 - - Returns: - str: 암호화된 비밀번호 - """ - password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) - return password_hash.hash(password) - -def verify_password(plain_password: str, hashed_password: str) -> bool: - # 1. 해쉬 검증 도구 생성 - password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) - # 2. 검증 수행 및 결과 반환 (true/false) - return password_hash.verify(plain_password, hashed_password) - -def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: - """ - JWT를 생성해 반환합니다. - - Args: - data (dict): JWT에 포함할 페이로드 정보. - expires_delta (Union[timedelta, None], optional): 만료 시간. 지정 없으면 기본값을 사용합니다. - - Returns: - str: 인코딩된 JWT 문자열. - """ - to_encode = data.copy() - now = datetime.now(timezone.utc) - if expires_delta: - expire = now + expires_delta - else: - expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - -def decode_token(token: str) -> dict[str, Any]: - return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - -if __name__ == "__main__": - password = "dhrdmstn" - hashed_password = hash_password(password) - print("Hashed Password:", hashed_password) - - # 검증 테스트 - print("Verification:", verify_password(password, hashed_password)) +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher +from pwdlib.hashers.bcrypt import BcryptHasher # ⚠️ 오타 주의: bcrypt +from datetime import datetime, timedelta, timezone +from jose import jwt +from typing import Any, Union + +SECRET_KEY = "your-secret-key" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +def hash_password(password: str) -> str: + """ + 비밀번호를 해시(암호화)하여 반환 + 사용자가 입력한 평문 비밀번호를 Argon2 알고리즘을 사용하여 + 복호화가 불가능한 해시 문자열로 변환 + + Args: + password(str): 평문 비밀번호 + + Returns: + str: 암호화된 비밀번호 + """ + password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) + return password_hash.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + # 1. 해쉬 검증 도구 생성 + password_hash = PasswordHash((Argon2Hasher(), BcryptHasher())) + # 2. 검증 수행 및 결과 반환 (true/false) + return password_hash.verify(plain_password, hashed_password) + +def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: + """ + JWT를 생성해 반환합니다. + + Args: + data (dict): JWT에 포함할 페이로드 정보. + expires_delta (Union[timedelta, None], optional): 만료 시간. 지정 없으면 기본값을 사용합니다. + + Returns: + str: 인코딩된 JWT 문자열. + """ + to_encode = data.copy() + now = datetime.now(timezone.utc) + if expires_delta: + expire = now + expires_delta + else: + expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def decode_token(token: str) -> dict[str, Any]: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + +if __name__ == "__main__": + password = "dhrdmstn" + hashed_password = hash_password(password) + print("Hashed Password:", hashed_password) + + # 검증 테스트 + print("Verification:", verify_password(password, hashed_password)) diff --git a/appserver/apps/calendar/exceptions.py b/appserver/apps/calendar/exceptions.py index cf0dcc7..5b7bfe9 100644 --- a/appserver/apps/calendar/exceptions.py +++ b/appserver/apps/calendar/exceptions.py @@ -1,6 +1,6 @@ -from fastapi import HTTPException, status - - +from fastapi import HTTPException, status + + class HostNotFoundError(HTTPException): def __init__(self): super().__init__( diff --git a/appserver/apps/calendar/models.py b/appserver/apps/calendar/models.py index 1bbe180..7545427 100644 --- a/appserver/apps/calendar/models.py +++ b/appserver/apps/calendar/models.py @@ -1,124 +1,124 @@ -from datetime import date, time, timezone, datetime -from typing import TYPE_CHECKING -from pydantic import AwareDatetime -from sqlalchemy_utc import UtcDateTime -from sqlmodel import SQLModel, Field, Relationship, Text, JSON, func, String, Column -from sqlalchemy.dialects.postgresql import JSONB -if TYPE_CHECKING: - from appserver.apps.account.models import User -# TYPE_CHECKING은 코드가 실행될 때는 무시하고, 타입 검사(IDE 등) 시에만 사용됨 -# User와 Calendar가 서로를 import하며 발생하는 순화 참조 에러 방지 - -class Calendar(SQLModel, table=True): - __tablename__ = "calendars" - - id: int = Field(default=None, primary_key=True) - # list[str]: ["FastAPI", "Python", "SQLModel"] - # DB 저장 시 문자열 '["A", "B"] JSON 형태로 변환하여 들어갑니다. - topics: list[str] = Field( - sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), - default_factory=list, - description="게스트와 나눌 주제들") - # PostgreSQL JSONB 자료형, 특별한 이유 없으면 JSONB 사용 권장 - # JSONB: 바이너리 형식, 공백제거, 키 순서 변경, 빠름, 인덱싱 지원, 쓰기비용 상대적으로 높음 - # 읽기 비용 낮음, 공간 효율성 높음, 중복키: 마지막 키로 대체, 빠른 질의 및 데이터 처리가 필요할 때 - # 기본적으로는 표준 JSON 타입을 쓰되, DB가 PostgresSQL일 경우에만 - # 더 빠르고 검색에 유리한 JSONB 형식을 사용하도록 유연하게 설계함. - - description: str = Field(sa_type=Text, description="게스트에게 보여 줄 설명") - google_calendar_id: str = Field(max_length=1024, description="Google Calandar") - - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - }, - ) - - # 1:1 관계 한 명의 호스트(User)는 하나의 캘린더만 가짐 - host_id: int = Field(foreign_key="users.id", unique=True) - host: "User" = Relationship( - back_populates="calendar", # 이 객체는 단 하나의 부모에게만 종속됨을 명시 - sa_relationship_kwargs={"uselist": False, "single_parent": True}, - ) - # 부모가 삭제되거나 관계가 끊기면 자식 객체도 삭제함 - # 유저가 탈퇴하면 그 유저의 개인 캘린더 설정도 함꼐 지워져야 하므로 이 설정을 추가 - - time_slots: list["TimeSlot"] = Relationship(back_populates="calendar") - - -class TimeSlot(SQLModel, table=True): - __tablename__ = "time_slots" - - id: int = Field(default=None, primary_key=True) - start_time: time # 예약 가능한 시작 시간 - end_time: time # 예약 가능 종료 시간 - # 요일 설정 0(월) ~ 6(일) 숫자를 리스트로 저장하여 다중 요일 선택 기능 - weekdays: list[int] = Field( - sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), - description="예약 가능한 요일들" - ) - - calendar_id: int = Field(foreign_key="calendars.id") - calendar: Calendar = Relationship(back_populates="time_slots") - bookings: list["Booking"] = Relationship(back_populates="time_slot") - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - }, - ) - -class Booking(SQLModel, table=True): - __tablename__ = "bookings" - - id: int = Field(default=None, primary_key=True) - when: date - topic: str - description: str = Field(sa_type=Text, description="예약 설명") - - time_slot_id: int = Field(foreign_key="time_slots.id") - time_slot: TimeSlot = Relationship(back_populates="bookings") - - guest_id: int = Field(foreign_key="users.id") - guest: "User" = Relationship(back_populates="bookings") - - created_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - }, - ) - updated_at: AwareDatetime = Field( - default=None, - nullable=False, - sa_type=UtcDateTime, - sa_column_kwargs={ - "server_default": func.now(), - "onupdate": lambda: datetime.now(timezone.utc), - }, +from datetime import date, time, timezone, datetime +from typing import TYPE_CHECKING +from pydantic import AwareDatetime +from sqlalchemy_utc import UtcDateTime +from sqlmodel import SQLModel, Field, Relationship, Text, JSON, func, String, Column +from sqlalchemy.dialects.postgresql import JSONB +if TYPE_CHECKING: + from appserver.apps.account.models import User +# TYPE_CHECKING은 코드가 실행될 때는 무시하고, 타입 검사(IDE 등) 시에만 사용됨 +# User와 Calendar가 서로를 import하며 발생하는 순화 참조 에러 방지 + +class Calendar(SQLModel, table=True): + __tablename__ = "calendars" + + id: int = Field(default=None, primary_key=True) + # list[str]: ["FastAPI", "Python", "SQLModel"] + # DB 저장 시 문자열 '["A", "B"] JSON 형태로 변환하여 들어갑니다. + topics: list[str] = Field( + sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), + default_factory=list, + description="게스트와 나눌 주제들") + # PostgreSQL JSONB 자료형, 특별한 이유 없으면 JSONB 사용 권장 + # JSONB: 바이너리 형식, 공백제거, 키 순서 변경, 빠름, 인덱싱 지원, 쓰기비용 상대적으로 높음 + # 읽기 비용 낮음, 공간 효율성 높음, 중복키: 마지막 키로 대체, 빠른 질의 및 데이터 처리가 필요할 때 + # 기본적으로는 표준 JSON 타입을 쓰되, DB가 PostgresSQL일 경우에만 + # 더 빠르고 검색에 유리한 JSONB 형식을 사용하도록 유연하게 설계함. + + description: str = Field(sa_type=Text, description="게스트에게 보여 줄 설명") + google_calendar_id: str = Field(max_length=1024, description="Google Calandar") + + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + }, + ) + + # 1:1 관계 한 명의 호스트(User)는 하나의 캘린더만 가짐 + host_id: int = Field(foreign_key="users.id", unique=True) + host: "User" = Relationship( + back_populates="calendar", # 이 객체는 단 하나의 부모에게만 종속됨을 명시 + sa_relationship_kwargs={"uselist": False, "single_parent": True}, + ) + # 부모가 삭제되거나 관계가 끊기면 자식 객체도 삭제함 + # 유저가 탈퇴하면 그 유저의 개인 캘린더 설정도 함꼐 지워져야 하므로 이 설정을 추가 + + time_slots: list["TimeSlot"] = Relationship(back_populates="calendar") + + +class TimeSlot(SQLModel, table=True): + __tablename__ = "time_slots" + + id: int = Field(default=None, primary_key=True) + start_time: time # 예약 가능한 시작 시간 + end_time: time # 예약 가능 종료 시간 + # 요일 설정 0(월) ~ 6(일) 숫자를 리스트로 저장하여 다중 요일 선택 기능 + weekdays: list[int] = Field( + sa_type=JSON().with_variant(JSONB(astext_type=Text()), "postgresql"), + description="예약 가능한 요일들" + ) + + calendar_id: int = Field(foreign_key="calendars.id") + calendar: Calendar = Relationship(back_populates="time_slots") + bookings: list["Booking"] = Relationship(back_populates="time_slot") + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + }, + ) + +class Booking(SQLModel, table=True): + __tablename__ = "bookings" + + id: int = Field(default=None, primary_key=True) + when: date + topic: str + description: str = Field(sa_type=Text, description="예약 설명") + + time_slot_id: int = Field(foreign_key="time_slots.id") + time_slot: TimeSlot = Relationship(back_populates="bookings") + + guest_id: int = Field(foreign_key="users.id") + guest: "User" = Relationship(back_populates="bookings") + + created_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + }, + ) + updated_at: AwareDatetime = Field( + default=None, + nullable=False, + sa_type=UtcDateTime, + sa_column_kwargs={ + "server_default": func.now(), + "onupdate": lambda: datetime.now(timezone.utc), + }, ) \ No newline at end of file diff --git a/appserver/apps/calendar/schemas.py b/appserver/apps/calendar/schemas.py index 0d5e229..1d74953 100644 --- a/appserver/apps/calendar/schemas.py +++ b/appserver/apps/calendar/schemas.py @@ -1,13 +1,13 @@ -from pydantic import AwareDatetime -from sqlmodel import SQLModel - +from pydantic import AwareDatetime +from sqlmodel import SQLModel + class CalendarOut(SQLModel): - topics: list[str] - description: str - + topics: list[str] + description: str + class CalendarDetailOut(CalendarOut): - host_id: int - google_calendar_id: str - created_at: AwareDatetime - updated_at: AwareDatetime - + host_id: int + google_calendar_id: str + created_at: AwareDatetime + updated_at: AwareDatetime + diff --git a/appserver/db.py b/appserver/db.py index 0e0716c..3c57831 100644 --- a/appserver/db.py +++ b/appserver/db.py @@ -1,52 +1,52 @@ -from sqlalchemy.ext.asyncio import ( - create_async_engine, - async_sessionmaker, - AsyncSession, - AsyncEngine, -) -from typing import Annotated # 타입 힌트에 메타데이터를 추가하는 도구 -from fastapi import Depends # 의존성 주입을 선언하는 함수입니다. - -# 1. Engine 생성: 데이터베이스로 가는 '고속도로'를 건설하는 함수 -def create_engine(dsn: str): - # create_async_engine: 비동기 방식으로 DB에 접속하는 엔진을 만듦 - # dsn: Database Source Name (DB 주소. 예: postgresql+asyncpg://...) - # echo=True: 실행되는 모든 SQL 쿼리를 터미널에 출력 (디버깅용 필수 설정) - return create_async_engine( - dsn, - echo=True, - ) - -# 2. Session Factory 생성: DB와 대화할 일꾼(Session)을 찍어내는 공장 -def create_session(async_engine: AsyncEngine | None = None): - # 만약 엔진이 전달되지 않았다면 새로 생성 (보통은 미리 만든 엔진을 재사용함) - if async_engine is None: - async_engine = create_engine() - - # async_sessionmaker: 세션을 관리하기 쉽게 만들어주는 팩토리 클래스 - return async_sessionmaker( - async_engine, - expire_on_commit=False, # commit 후에도 객체의 데이터를 메모리에 유지 (비동기 효율성) - autoflush=False, # 의도하지 않은 시점에 DB에 데이터가 반영되는 것을 방지 - class_=AsyncSession, # 이 팩토리가 찍어낼 일꾼의 타입은 비동기 세션임을 명시 - ) - -# 3. Dependency Injection용 함수: API 요청이 들어올 때마다 세션을 빌려주는 역할 -async def use_session(): - # async with: 작업이 끝나면 자동으로 세션을 닫아줌 (안전한 자원 관리) - async with async_session_factory() as session: - yield session # FastAPI의 Depends()를 통해 세션을 API 핸들러로 전달 - -# --- 실제 실행 및 초기화 --- - -DSN = "sqlite+aiosqlite:///./local.db" -# 프로젝트 전역에서 사용할 단 하나의 엔진 -engine = create_engine(DSN) - -# 프로젝트 전역에서 상용할 세션 공장 -async_session_factory = create_session(engine) - -# 의존성 주입을 위한 비동기 DB 세션 타입 별칭 (Annotated와 Depends 조합) -# DBSessionDep은 비동기 DB 세션 타입이면서, use_session 함수를 통해 -# 자동으로 빌려오겠다는 선언 +from sqlalchemy.ext.asyncio import ( + create_async_engine, + async_sessionmaker, + AsyncSession, + AsyncEngine, +) +from typing import Annotated # 타입 힌트에 메타데이터를 추가하는 도구 +from fastapi import Depends # 의존성 주입을 선언하는 함수입니다. + +# 1. Engine 생성: 데이터베이스로 가는 '고속도로'를 건설하는 함수 +def create_engine(dsn: str): + # create_async_engine: 비동기 방식으로 DB에 접속하는 엔진을 만듦 + # dsn: Database Source Name (DB 주소. 예: postgresql+asyncpg://...) + # echo=True: 실행되는 모든 SQL 쿼리를 터미널에 출력 (디버깅용 필수 설정) + return create_async_engine( + dsn, + echo=True, + ) + +# 2. Session Factory 생성: DB와 대화할 일꾼(Session)을 찍어내는 공장 +def create_session(async_engine: AsyncEngine | None = None): + # 만약 엔진이 전달되지 않았다면 새로 생성 (보통은 미리 만든 엔진을 재사용함) + if async_engine is None: + async_engine = create_engine() + + # async_sessionmaker: 세션을 관리하기 쉽게 만들어주는 팩토리 클래스 + return async_sessionmaker( + async_engine, + expire_on_commit=False, # commit 후에도 객체의 데이터를 메모리에 유지 (비동기 효율성) + autoflush=False, # 의도하지 않은 시점에 DB에 데이터가 반영되는 것을 방지 + class_=AsyncSession, # 이 팩토리가 찍어낼 일꾼의 타입은 비동기 세션임을 명시 + ) + +# 3. Dependency Injection용 함수: API 요청이 들어올 때마다 세션을 빌려주는 역할 +async def use_session(): + # async with: 작업이 끝나면 자동으로 세션을 닫아줌 (안전한 자원 관리) + async with async_session_factory() as session: + yield session # FastAPI의 Depends()를 통해 세션을 API 핸들러로 전달 + +# --- 실제 실행 및 초기화 --- + +DSN = "sqlite+aiosqlite:///./local.db" +# 프로젝트 전역에서 사용할 단 하나의 엔진 +engine = create_engine(DSN) + +# 프로젝트 전역에서 상용할 세션 공장 +async_session_factory = create_session(engine) + +# 의존성 주입을 위한 비동기 DB 세션 타입 별칭 (Annotated와 Depends 조합) +# DBSessionDep은 비동기 DB 세션 타입이면서, use_session 함수를 통해 +# 자동으로 빌려오겠다는 선언 DbSessionDep = Annotated[AsyncSession, Depends(use_session)] \ No newline at end of file diff --git a/appserver/libs/datetime/calendar.py b/appserver/libs/datetime/calendar.py index 4a7e5ed..ea58c2e 100644 --- a/appserver/libs/datetime/calendar.py +++ b/appserver/libs/datetime/calendar.py @@ -1,94 +1,94 @@ -from datetime import date, timedelta - -def get_start_weekday_of_month(year:int, month:int) -> int: - """ - 특정 연도와 월의 1일이 무슨 요일인지 계산합니다. - - Args: - year (int): 계산하려는 연도 (예: 2024) - month (int): 계산하려는 월 (1~12) - - Returns: - int: 요일을 나타내는 정수 (0: 월요일, 1: 화요일, ..., 6: 일요일) - - >>> get_start_weekday_of_month(2024, 12) - 6 - >>> get_start_weekday_of_month(2025, 2) - 5 - """ - result = date(year, month, 1) - return result.weekday() - -def get_last_day_of_month(year: int, month: int) -> int: - """특정 연도와 월의 마지막 날짜(일)를 반환합니다. - - Args: - year (int): 조회하고자 하는 연도. - month (int): 조회하고자 하는 월 (1~12). - - Returns: - int: 해당 월의 마지막 날짜 (28, 29, 30, 또는 31). - - # 2. Docstring 테스트 케이스 수정 - >>> get_last_day_of_month(2024, 2) # 윤년 - 29 - >>> get_last_day_of_month(2025, 2) # 평년 - 28 - """ - # [로직 설명] 다음 달의 1일을 구한 뒤 하루를 빼면 이번 달의 마지막 날이 됩니다. - if month == 12: - # 12월인 경우 다음 해 1월 1일을 기준점으로 잡음 - next_month = date(year + 1, 1, 1) - else: - # 그 외에는 다음 달 1일을 기준점으로 잡음 - next_month = date(year, month + 1, 1) - - # [핵심] timedelta(days=1)을 빼서 이번 달의 마지막 날 객체를 생성 - result = next_month - timedelta(days=1) - - return result.day - -def get_range_days_of_month(year:int, month:int): - """특정 월의 달력 표시를 위한 날짜 리스트를 생성합니다. - - 1일이 시작되기 전의 빈칸은 0으로 채워지며, - 일요일(0)부터 시작하는 달력 한 달 치 데이터의 기반이 됩니다. - - Args: - year (int): 조회할 연도. - month (int): 조회할 월 (1~12). - - Returns: - list[int]: 0(빈칸)과 실제 날짜(1~마지막날)가 섞인 정수 리스트. - >>> result = get_range_days_of_month(2024, 3) - >>> result[:5] - [0, 0, 0, 0, 0] - >>> result[5] - 1 - >>> len(result) - 36 - >>> result = get_range_days_of_month(2024, 2) # 윤년 - >>> result[:4] - [0, 0, 0, 0] - >>> result[4] - 1 - >>> len(result) - 33 - """ - # 월의 시작 요일을 가져옴(월요일=0~일요일=6) - start_weekday = get_start_weekday_of_month(year, month) - - # 월의 마지막 날짜를 가져옴 - last_day = get_last_day_of_month(year, month) - - # 월요일 = 0을 월요일=1로 변환(일요일=0으로 만들기 위해) - start_weekday = (start_weekday + 1) % 7 - - # 결과 리스트 생성 - result = [0] * start_weekday # 시작 요일 전까지 0으로 채움 - - # 1일부터 마지막 날까지 추가 - # for day in range(1, last_day + 1): - # result.append(day) - +from datetime import date, timedelta + +def get_start_weekday_of_month(year:int, month:int) -> int: + """ + 특정 연도와 월의 1일이 무슨 요일인지 계산합니다. + + Args: + year (int): 계산하려는 연도 (예: 2024) + month (int): 계산하려는 월 (1~12) + + Returns: + int: 요일을 나타내는 정수 (0: 월요일, 1: 화요일, ..., 6: 일요일) + + >>> get_start_weekday_of_month(2024, 12) + 6 + >>> get_start_weekday_of_month(2025, 2) + 5 + """ + result = date(year, month, 1) + return result.weekday() + +def get_last_day_of_month(year: int, month: int) -> int: + """특정 연도와 월의 마지막 날짜(일)를 반환합니다. + + Args: + year (int): 조회하고자 하는 연도. + month (int): 조회하고자 하는 월 (1~12). + + Returns: + int: 해당 월의 마지막 날짜 (28, 29, 30, 또는 31). + + # 2. Docstring 테스트 케이스 수정 + >>> get_last_day_of_month(2024, 2) # 윤년 + 29 + >>> get_last_day_of_month(2025, 2) # 평년 + 28 + """ + # [로직 설명] 다음 달의 1일을 구한 뒤 하루를 빼면 이번 달의 마지막 날이 됩니다. + if month == 12: + # 12월인 경우 다음 해 1월 1일을 기준점으로 잡음 + next_month = date(year + 1, 1, 1) + else: + # 그 외에는 다음 달 1일을 기준점으로 잡음 + next_month = date(year, month + 1, 1) + + # [핵심] timedelta(days=1)을 빼서 이번 달의 마지막 날 객체를 생성 + result = next_month - timedelta(days=1) + + return result.day + +def get_range_days_of_month(year:int, month:int): + """특정 월의 달력 표시를 위한 날짜 리스트를 생성합니다. + + 1일이 시작되기 전의 빈칸은 0으로 채워지며, + 일요일(0)부터 시작하는 달력 한 달 치 데이터의 기반이 됩니다. + + Args: + year (int): 조회할 연도. + month (int): 조회할 월 (1~12). + + Returns: + list[int]: 0(빈칸)과 실제 날짜(1~마지막날)가 섞인 정수 리스트. + >>> result = get_range_days_of_month(2024, 3) + >>> result[:5] + [0, 0, 0, 0, 0] + >>> result[5] + 1 + >>> len(result) + 36 + >>> result = get_range_days_of_month(2024, 2) # 윤년 + >>> result[:4] + [0, 0, 0, 0] + >>> result[4] + 1 + >>> len(result) + 33 + """ + # 월의 시작 요일을 가져옴(월요일=0~일요일=6) + start_weekday = get_start_weekday_of_month(year, month) + + # 월의 마지막 날짜를 가져옴 + last_day = get_last_day_of_month(year, month) + + # 월요일 = 0을 월요일=1로 변환(일요일=0으로 만들기 위해) + start_weekday = (start_weekday + 1) % 7 + + # 결과 리스트 생성 + result = [0] * start_weekday # 시작 요일 전까지 0으로 채움 + + # 1일부터 마지막 날까지 추가 + # for day in range(1, last_day + 1): + # result.append(day) + return result + list(range(1, last_day + 1)) \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 0bf778d..0c8fd69 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,924 +1,924 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "aiosqlite" -version = "0.22.1" -description = "asyncio bridge to the standard sqlite3 module" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, - {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, -] - -[package.extras] -dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] -docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] - -[[package]] -name = "alembic" -version = "1.17.2" -description = "A database migration tool for SQLAlchemy." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6"}, - {file = "alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e"}, -] - -[package.dependencies] -Mako = "*" -SQLAlchemy = ">=1.4.0" -tomli = {version = "*", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.12" - -[package.extras] -tz = ["tzdata"] - -[[package]] -name = "argon2-cffi" -version = "25.1.0" -description = "Argon2 for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"}, - {file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"}, -] - -[package.dependencies] -argon2-cffi-bindings = "*" - -[[package]] -name = "argon2-cffi-bindings" -version = "25.1.0" -description = "Low-level CFFI bindings for Argon2" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6"}, - {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98"}, - {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94"}, - {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e"}, - {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d"}, - {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584"}, - {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690"}, - {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520"}, - {file = "argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d"}, -] - -[package.dependencies] -cffi = [ - {version = ">=1.0.1", markers = "python_version < \"3.14\""}, - {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, -] - -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." -optional = false -python-versions = "<3.11,>=3.8" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, - {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, -] - -[[package]] -name = "bcrypt" -version = "5.0.0" -description = "Modern password hashing for your software and your servers" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, - {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, - {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f"}, - {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86"}, - {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23"}, - {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2"}, - {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83"}, - {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746"}, - {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e"}, - {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d"}, - {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba"}, - {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41"}, - {file = "bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861"}, - {file = "bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e"}, - {file = "bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5"}, - {file = "bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef"}, - {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4"}, - {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf"}, - {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da"}, - {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9"}, - {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f"}, - {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493"}, - {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b"}, - {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c"}, - {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4"}, - {file = "bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e"}, - {file = "bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d"}, - {file = "bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993"}, - {file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"}, - {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"}, - {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"}, - {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"}, - {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"}, - {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"}, - {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"}, - {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"}, - {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"}, - {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"}, - {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"}, - {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"}, - {file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"}, - {file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"}, - {file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"}, - {file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"}, - {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"}, - {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"}, - {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"}, - {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"}, - {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"}, - {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"}, - {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"}, - {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"}, - {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"}, - {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"}, - {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"}, - {file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"}, - {file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"}, - {file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"}, - {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"}, - {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"}, - {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"}, - {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"}, - {file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"}, -] - -[package.extras] -tests = ["pytest (>=3.2.1,!=3.3.0)"] -typecheck = ["mypy"] - -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "ecdsa" -version = "0.19.1" -description = "ECDSA cryptographic signature library (pure python)" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" -groups = ["main"] -files = [ - {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, - {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, -] - -[package.dependencies] -six = ">=1.9.0" - -[package.extras] -gmpy = ["gmpy"] -gmpy2 = ["gmpy2"] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "greenlet" -version = "3.3.0" -description = "Lightweight in-process concurrent programming" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b"}, - {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5"}, - {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9"}, - {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d"}, - {file = "greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082"}, - {file = "greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45"}, - {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948"}, - {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794"}, - {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5"}, - {file = "greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71"}, - {file = "greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7"}, - {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b"}, - {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53"}, - {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614"}, - {file = "greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39"}, - {file = "greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492"}, - {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527"}, - {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39"}, - {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8"}, - {file = "greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38"}, - {file = "greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45"}, - {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955"}, - {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55"}, - {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc"}, - {file = "greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170"}, - {file = "greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221"}, - {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b"}, - {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd"}, - {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9"}, - {file = "greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb"}, -] - -[package.extras] -docs = ["Sphinx", "furo"] -test = ["objgraph", "psutil", "setuptools"] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "mako" -version = "1.3.10" -description = "A super-fast templating language that borrows the best ideas from the existing templating languages." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, - {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, -] - -[package.dependencies] -MarkupSafe = ">=0.9.2" - -[package.extras] -babel = ["Babel"] -lingua = ["lingua"] -testing = ["pytest"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "pwdlib" -version = "0.3.0" -description = "Modern password hashing for Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d"}, - {file = "pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc"}, -] - -[package.dependencies] -argon2-cffi = {version = ">=23.1.0,<26", optional = true, markers = "extra == \"argon2\""} -bcrypt = {version = ">=4.1.2,<6", optional = true, markers = "extra == \"bcrypt\""} - -[package.extras] -argon2 = ["argon2-cffi (>=23.1.0,<26)"] -bcrypt = ["bcrypt (>=4.1.2,<6)"] - -[[package]] -name = "pyasn1" -version = "0.6.1" -description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, -] - -[[package]] -name = "pycparser" -version = "2.23" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, - {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, -] - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pytest" -version = "9.0.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, - {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, - {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, -] - -[package.dependencies] -backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} -pytest = ">=8.2,<10" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "python-jose" -version = "3.5.0" -description = "JOSE implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, - {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, -] - -[package.dependencies] -ecdsa = "!=0.15" -pyasn1 = ">=0.5.0" -rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" - -[package.extras] -cryptography = ["cryptography (>=3.4.0)"] -pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] -pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "rsa" -version = "4.2" -description = "Pure-Python RSA implementation" -optional = false -python-versions = "*" -groups = ["main"] -markers = "python_version >= \"3.14\"" -files = [ - {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - -[[package]] -name = "rsa" -version = "4.9.1" -description = "Pure-Python RSA implementation" -optional = false -python-versions = "<4,>=3.6" -groups = ["main"] -markers = "python_version < \"3.14\"" -files = [ - {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, - {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - -[[package]] -name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "sqlalchemy" -version = "2.0.45" -description = "Database Abstraction Library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a"}, - {file = "sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177"}, - {file = "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953"}, - {file = "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a"}, - {file = "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee"}, - {file = "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6"}, - {file = "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f"}, - {file = "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177"}, - {file = "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b"}, - {file = "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5964f832431b7cdfaaa22a660b4c7eb1dfcd6ed41375f67fd3e3440fd95cb3cc"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee580ab50e748208754ae8980cec79ec205983d8cf8b3f7c39067f3d9f2c8e22"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13e27397a7810163440c6bfed6b3fe46f1bfb2486eb540315a819abd2c004128"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ed3635353e55d28e7f4a95c8eda98a5cdc0a0b40b528433fbd41a9ae88f55b3d"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:db6834900338fb13a9123307f0c2cbb1f890a8656fcd5e5448ae3ad5bbe8d312"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-win32.whl", hash = "sha256:1d8b4a7a8c9b537509d56d5cd10ecdcfbb95912d72480c8861524efecc6a3fff"}, - {file = "sqlalchemy-2.0.45-cp38-cp38-win_amd64.whl", hash = "sha256:ebd300afd2b62679203435f596b2601adafe546cb7282d5a0cd3ed99e423720f"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d29b2b99d527dbc66dd87c3c3248a5dd789d974a507f4653c969999fc7c1191b"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59a8b8bd9c6bedf81ad07c8bd5543eedca55fe9b8780b2b628d495ba55f8db1e"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd93c6f5d65f254ceabe97548c709e073d6da9883343adaa51bf1a913ce93f8e"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d0beadc2535157070c9c17ecf25ecec31e13c229a8f69196d7590bde8082bf1"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e057f928ffe9c9b246a55b469c133b98a426297e1772ad24ce9f0c47d123bd5b"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-win32.whl", hash = "sha256:c1c2091b1489435ff85728fafeb990f073e64f6f5e81d5cd53059773e8521eb6"}, - {file = "sqlalchemy-2.0.45-cp39-cp39-win_amd64.whl", hash = "sha256:56ead1f8dfb91a54a28cd1d072c74b3d635bcffbd25e50786533b822d4f2cde2"}, - {file = "sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0"}, - {file = "sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88"}, -] - -[package.dependencies] -greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} -typing-extensions = ">=4.6.0" - -[package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] -aioodbc = ["aioodbc", "greenlet (>=1)"] -aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (>=1)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] -mssql = ["pyodbc"] -mssql-pymssql = ["pymssql"] -mssql-pyodbc = ["pyodbc"] -mypy = ["mypy (>=0.910)"] -mysql = ["mysqlclient (>=1.4.0)"] -mysql-connector = ["mysql-connector-python"] -oracle = ["cx_oracle (>=8)"] -oracle-oracledb = ["oracledb (>=1.0.1)"] -postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] -postgresql-pg8000 = ["pg8000 (>=1.29.1)"] -postgresql-psycopg = ["psycopg (>=3.0.7)"] -postgresql-psycopg2binary = ["psycopg2-binary"] -postgresql-psycopg2cffi = ["psycopg2cffi"] -postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] -pymysql = ["pymysql"] -sqlcipher = ["sqlcipher3_binary"] - -[[package]] -name = "sqlalchemy-utc" -version = "0.14.0" -description = "SQLAlchemy type to store aware datetime values" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "SQLAlchemy-Utc-0.14.0.tar.gz", hash = "sha256:8e041624595b66d7b1d5ea8b6de486df5c1b9352697f3b24f862f0ded56cd7aa"}, - {file = "SQLAlchemy_Utc-0.14.0-py2.py3-none-any.whl", hash = "sha256:d2379eed5cce372128b5e744ce382decd262b2c742ab31f7f22ca11c6647f60b"}, -] - -[package.dependencies] -setuptools = "*" -SQLAlchemy = ">=0.9.0" - -[[package]] -name = "tomli" -version = "2.3.0" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, - {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, - {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, - {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, - {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, - {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, - {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, - {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, - {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, - {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, - {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, - {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, - {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, - {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, - {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, - {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, - {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, - {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, - {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, - {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, - {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, - {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, - {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, - {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, - {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, - {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, - {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, - {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] -markers = {dev = "python_version < \"3.14\""} - -[metadata] -lock-version = "2.1" -python-versions = ">=3.10" -content-hash = "ac9fd150d50b493b299c784f589e65012ad1dff8e3dbb2269167e767014879e3" +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "aiosqlite" +version = "0.22.1" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, + {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, +] + +[package.extras] +dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] + +[[package]] +name = "alembic" +version = "1.17.2" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6"}, + {file = "alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.4.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.12" + +[package.extras] +tz = ["tzdata"] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"}, + {file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6"}, + {file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98"}, + {file = "argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690"}, + {file = "argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520"}, + {file = "argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d"}, +] + +[package.dependencies] +cffi = [ + {version = ">=1.0.1", markers = "python_version < \"3.14\""}, + {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5"}, + {file = "bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993"}, + {file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"}, + {file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"}, + {file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"}, + {file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"}, + {file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" +groups = ["main"] +files = [ + {file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"}, + {file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "greenlet" +version = "3.3.0" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b"}, + {file = "greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5"}, + {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9"}, + {file = "greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d"}, + {file = "greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082"}, + {file = "greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45"}, + {file = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948"}, + {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794"}, + {file = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5"}, + {file = "greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71"}, + {file = "greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7"}, + {file = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b"}, + {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53"}, + {file = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614"}, + {file = "greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39"}, + {file = "greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492"}, + {file = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527"}, + {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39"}, + {file = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8"}, + {file = "greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38"}, + {file = "greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45"}, + {file = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955"}, + {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55"}, + {file = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc"}, + {file = "greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170"}, + {file = "greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221"}, + {file = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b"}, + {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd"}, + {file = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9"}, + {file = "greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "mako" +version = "1.3.10" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"}, + {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pwdlib" +version = "0.3.0" +description = "Modern password hashing for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d"}, + {file = "pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc"}, +] + +[package.dependencies] +argon2-cffi = {version = ">=23.1.0,<26", optional = true, markers = "extra == \"argon2\""} +bcrypt = {version = ">=4.1.2,<6", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=23.1.0,<26)"] +bcrypt = ["bcrypt (>=4.1.2,<6)"] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, +] + +[package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-jose" +version = "3.5.0" +description = "JOSE implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, + {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, +] + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = ">=0.5.0" +rsa = ">=4.0,<4.1.1 || >4.1.1,<4.4 || >4.4,<5.0" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "rsa" +version = "4.2" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "rsa" +version = "4.9.1" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "<4,>=3.6" +groups = ["main"] +markers = "python_version < \"3.14\"" +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.45" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a"}, + {file = "sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177"}, + {file = "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953"}, + {file = "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a"}, + {file = "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee"}, + {file = "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6"}, + {file = "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f"}, + {file = "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177"}, + {file = "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b"}, + {file = "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5964f832431b7cdfaaa22a660b4c7eb1dfcd6ed41375f67fd3e3440fd95cb3cc"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee580ab50e748208754ae8980cec79ec205983d8cf8b3f7c39067f3d9f2c8e22"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13e27397a7810163440c6bfed6b3fe46f1bfb2486eb540315a819abd2c004128"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ed3635353e55d28e7f4a95c8eda98a5cdc0a0b40b528433fbd41a9ae88f55b3d"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:db6834900338fb13a9123307f0c2cbb1f890a8656fcd5e5448ae3ad5bbe8d312"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-win32.whl", hash = "sha256:1d8b4a7a8c9b537509d56d5cd10ecdcfbb95912d72480c8861524efecc6a3fff"}, + {file = "sqlalchemy-2.0.45-cp38-cp38-win_amd64.whl", hash = "sha256:ebd300afd2b62679203435f596b2601adafe546cb7282d5a0cd3ed99e423720f"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d29b2b99d527dbc66dd87c3c3248a5dd789d974a507f4653c969999fc7c1191b"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59a8b8bd9c6bedf81ad07c8bd5543eedca55fe9b8780b2b628d495ba55f8db1e"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd93c6f5d65f254ceabe97548c709e073d6da9883343adaa51bf1a913ce93f8e"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d0beadc2535157070c9c17ecf25ecec31e13c229a8f69196d7590bde8082bf1"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e057f928ffe9c9b246a55b469c133b98a426297e1772ad24ce9f0c47d123bd5b"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-win32.whl", hash = "sha256:c1c2091b1489435ff85728fafeb990f073e64f6f5e81d5cd53059773e8521eb6"}, + {file = "sqlalchemy-2.0.45-cp39-cp39-win_amd64.whl", hash = "sha256:56ead1f8dfb91a54a28cd1d072c74b3d635bcffbd25e50786533b822d4f2cde2"}, + {file = "sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0"}, + {file = "sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "sqlalchemy-utc" +version = "0.14.0" +description = "SQLAlchemy type to store aware datetime values" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "SQLAlchemy-Utc-0.14.0.tar.gz", hash = "sha256:8e041624595b66d7b1d5ea8b6de486df5c1b9352697f3b24f862f0ded56cd7aa"}, + {file = "SQLAlchemy_Utc-0.14.0-py2.py3-none-any.whl", hash = "sha256:d2379eed5cce372128b5e744ce382decd262b2c742ab31f7f22ca11c6647f60b"}, +] + +[package.dependencies] +setuptools = "*" +SQLAlchemy = ">=0.9.0" + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.14\""} + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "ac9fd150d50b493b299c784f589e65012ad1dff8e3dbb2269167e767014879e3" diff --git a/pyproject.toml b/pyproject.toml index 6a2bc08..8c6ce47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,33 @@ -[project] -name = "fastapi-meeting-service" -version = "0.1.0" -description = "" -authors = [ - {name = "OKEunsu",email = "esok0617@gmail.com"} -] -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "sqlalchemy-utc (>=0.14.0,<0.15.0)", - "aiosqlite (>=0.22.1,<0.23.0)", - "alembic (>=1.17.2,<2.0.0)", - "greenlet (>=3.3.0,<4.0.0)", - "typing-extensions (>=4.0.0,<5.0.0)", - "pwdlib[argon2,bcrypt] (>=0.3.0,<0.4.0)", - "python-jose[crpytography] (>=3.5.0,<4.0.0)" -] - - -[build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" - -[dependency-groups] -dev = [ - "pytest (>=9.0.2,<10.0.0)", - "pytest-asyncio (>=1.3.0,<2.0.0)" -] - +[project] +name = "fastapi-meeting-service" +version = "0.1.0" +description = "" +authors = [ + {name = "OKEunsu",email = "esok0617@gmail.com"} +] +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "sqlalchemy-utc (>=0.14.0,<0.15.0)", + "aiosqlite (>=0.22.1,<0.23.0)", + "alembic (>=1.17.2,<2.0.0)", + "greenlet (>=3.3.0,<4.0.0)", + "typing-extensions (>=4.0.0,<5.0.0)", + "pwdlib[argon2,bcrypt] (>=0.3.0,<0.4.0)", + "python-jose[crpytography] (>=3.5.0,<4.0.0)" +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "pytest (>=9.0.2,<10.0.0)", + "pytest-asyncio (>=1.3.0,<2.0.0)" +] + [tool.pytest.ini_options] minversion = "8.3.4" env = [] @@ -40,5 +40,5 @@ norecursedirs = ["alembic"] filterwarnings = ["error", "ignore::DeprecationWarning:etcd3.*:"] log_cli = true log_cli_level = "WARNING" -log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" -log_cli_date_format = "%Y-%m-%d %H:%M:%S" +log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..25befe3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,61 @@ +aiosqlite==0.22.1 +alembic==1.17.2 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.0 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 +bcrypt==5.0.0 +certifi==2025.11.12 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +colorama==0.4.6 +dnspython==2.8.0 +ecdsa==0.19.1 +email-validator==2.3.0 +exceptiongroup==1.3.1 +fastapi==0.127.0 +greenlet==3.3.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +Jinja2==3.1.6 +Mako==1.3.10 +MarkupSafe==3.0.3 +orjson==3.11.8 +packaging==25.0 +pluggy==1.6.0 +pycparser==2.23 +pydantic==2.12.5 +pydantic-extra-types==2.10.6 +pydantic-settings==2.12.0 +pydantic_core==2.41.5 +pwdlib==0.3.0 +pyasn1==0.6.1 +pytest==9.0.2 +pytest-asyncio==1.3.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +python-jose==3.5.0 +python-multipart==0.0.21 +PyYAML==6.0.3 +requests==2.32.5 +rich==14.2.0 +rsa==4.9.1 +sentry-sdk==2.48.0 +six==1.17.0 +SQLAlchemy==2.0.45 +SQLAlchemy-Utc==0.14.0 +sqlmodel==0.0.30 +starlette==0.50.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +ujson==5.11.0 +urllib3==2.6.2 +uvicorn==0.40.0 +watchfiles==1.1.1 +websockets==15.0.1 diff --git a/study_plan.ipynb b/study_plan.ipynb index a539622..29906da 100644 --- a/study_plan.ipynb +++ b/study_plan.ipynb @@ -1,417 +1,417 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 📘 FastAPI 완전 정복 노트\n", - "\n", - "이 노트북은 여러분이 작성한 모든 코드를 직접 실행해보고 검증할 수 있는 통합 실습 환경입니다.\n", - "DB 생성부터 API 호출, 보안 검증, 그리고 마이그레이션까지 순서대로 진행하며 전체 흐름을 익혀봅시다!" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. 🛠 환경 설정 (Imports & Setup)\n", - "\n", - "프로젝트의 모든 모듈을 불러올 수 있도록 경로를 설정하고 필요한 라이브러리를 임포트합니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import sys\n", - "import os\n", - "from typing import AsyncGenerator\n", - "\n", - "# 현재 프로젝트 루트를 파이썬 경로에 추가\n", - "current_dir = os.getcwd()\n", - "if current_dir not in sys.path:\n", - " sys.path.append(current_dir)\n", - "\n", - "import asyncio\n", - "import nest_asyncio\n", - "nest_asyncio.apply()\n", - "\n", - "import pytest\n", - "from httpx import AsyncClient, ASGITransport\n", - "from sqlmodel import SQLModel, select\n", - "from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession\n", - "from sqlalchemy.orm import sessionmaker\n", - "\n", - "# FastAPI 앱 및 모델 임포트\n", - "from fastapi import FastAPI\n", - "from appserver.app import include_routers\n", - "from appserver.db import use_session\n", - "from appserver.apps.account.models import User\n", - "from appserver.apps.account.schemas import UserCreatePayload, LoginPayload\n", - "from appserver.apps.account.utils import hash_password, verify_password\n", - "from dataclasses import dataclass\n", - "\n", - "print(\"✅ 모든 라이브러리 임포트 완료!\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 2. 🗄 데이터베이스 구축 (In-Memory DB)\n", - "\n", - "실제 서버를 띄우지 않고, 노트북 안에서만 사용할 \"가짜 DB(메모리 DB)\"를 만듭니다.\n", - "이 단계가 성공해야 데이터를 저장하고 불러올 수 있습니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# 비동기 엔진 생성 (메모리 DB)\n", - "test_engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n", - "\n", - "async def init_db():\n", - " async with test_engine.begin() as conn:\n", - " # 모든 테이블 생성 (User 테이블 등)\n", - " await conn.run_sync(SQLModel.metadata.create_all)\n", - " print(\"✅ 데이터베이스 테이블 생성 완료!\")\n", - "\n", - "# 주피터는 이미 비동기 루프가 돌고 있으므로 await로 바로 실행\n", - "await init_db()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3. 🛡 로직 단위 테스트: 비밀번호 해싱\n", - "\n", - "`appserver/apps/account/utils.py`의 핵심 보안 기능을 먼저 검증합니다.\n", - "내가 입력한 비밀번호가 DB에 어떻게 저장되는지 눈으로 확인하세요." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "raw_pw = \"ilovepython\"\n", - "hashed_pw = hash_password(raw_pw)\n", - "\n", - "print(f\"🔑 원본 비밀번호: {raw_pw}\")\n", - "print(f\"🔒 해시된 비밀번호: {hashed_pw}\")\n", - "\n", - "# 검증 테스트\n", - "assert verify_password(raw_pw, hashed_pw) == True, \"비밀번호 검증 실패!\"\n", - "assert verify_password(\"wrongpw\", hashed_pw) == False, \"틀린 비밀번호가 통과됨!\"\n", - "print(\"✅ 비밀번호 보안 로직 정상 작동!\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 4. 🌐 가상 API 클라이언트 생성\n", - "\n", - "브라우저 없이 코드로 서버와 통신할 수 있는 `AsyncClient`를 만듭니다.\n", - "이 클라이언트가 여러분 대신 `/account/signup`, `/account/login` 등으로 요청을 보낼 겁니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# 1. FastAPI 앱 인스턴스 생성\n", - "app = FastAPI()\n", - "include_routers(app)\n", - "\n", - "# 2. DB 의존성 오버라이드 (가짜 DB 사용하도록 연결)\n", - "async def override_use_session() -> AsyncGenerator[AsyncSession, None]:\n", - " async_session = sessionmaker(\n", - " test_engine, class_=AsyncSession, expire_on_commit=False\n", - " )\n", - " async with async_session() as session:\n", - " yield session\n", - "\n", - "app.dependency_overrides[use_session] = override_use_session\n", - "\n", - "# 3. 클라이언트 생성\n", - "client = AsyncClient(transport=ASGITransport(app=app), base_url=\"http://test\")\n", - "print(\"✅ 가상 API 클라이언트 준비 완료!\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 5. 🚀 실전 시나리오 1: 회원가입 (Sign Up)\n", - "\n", - "유저가 회원가입 폼을 작성해서 제출하는 상황을 시뮬레이션합니다.\n", - "직접 JSON 데이터를 만들어서 POST 요청을 보내봅니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "signup_data = {\n", - " \"username\": \"fastapi_master\",\n", - " \"password\": \"code1234\",\n", - " \"password_again\": \"code1234\",\n", - " \"email\": \"master@example.com\",\n", - " \"display_name\": \"파이썬고수\"\n", - "}\n", - "\n", - "response = await client.post(\"/account/signup\", json=signup_data)\n", - "\n", - "print(f\"📡 응답 상태 코드: {response.status_code}\")\n", - "print(f\"📄 응답 데이터: {response.json()}\")\n", - "\n", - "if response.status_code == 201:\n", - " print(\"✅ 회원가입 성공!\")\n", - "else:\n", - " print(\"❌ 회원가입 실패 (로그 확인 필요)\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 6. 🔍 DB 확인: 진짜 저장되었나요?\n", - "\n", - "API는 성공했다고 하지만, 진짜 DB에 데이터가 들어갔는지 의심스러우시죠?\n", - "직접 SELECT 쿼리를 날려서 확인해봅시다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)\n", - "\n", - "async with async_session() as session:\n", - " # SELECT * FROM user WHERE username = 'fastapi_master'\n", - " stmt = select(User).where(User.username == \"fastapi_master\")\n", - " result = await session.execute(stmt)\n", - " user = result.scalar_one_or_none()\n", - "\n", - " if user:\n", - " print(f\"✅ DB 조회 성공!\")\n", - " print(f\"ID: {user.id}\")\n", - " print(f\"Username: {user.username}\")\n", - " print(f\"Email: {user.email}\")\n", - " print(f\"Hashed Password: {user.hashed_password} (안전하게 암호화됨)\")\n", - " else:\n", - " print(\"❌ DB에 유저가 없습니다!\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 7. 🚀 실전 시나리오 2: 로그인 (Login)\n", - "\n", - "이제 회원가입한 아이디로 로그인을 시도합니다.\n", - "성공하면 **Access Token(신분증)**을 쿠키나 바디로 받아야 합니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "login_data = {\n", - " \"username\": \"fastapi_master\",\n", - " \"password\": \"code1234\"\n", - "}\n", - "\n", - "response = await client.post(\"/account/login\", json=login_data)\n", - "\n", - "print(f\"📡 응답 상태 코드: {response.status_code}\")\n", - "print(f\"📄 응답 바디: {response.json()}\")\n", - "\n", - "# 쿠키 확인\n", - "cookies = response.cookies\n", - "auth_token = cookies.get(\"auth_token\")\n", - "\n", - "if auth_token:\n", - " print(f\"\\n🍪 발급된 쿠키(auth_token): {auth_token[:20]}... (생략)\")\n", - " print(\"✅ 로그인 및 토큰 발급 성공!\")\n", - "else:\n", - " print(\"❌ 쿠키가 없습니다. 로그인 로직을 점검하세요.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 8. 🚀 실전 시나리오 3: 내 정보 수정 (Update)\n", - "\n", - "로그인 상태(`client`가 쿠기를 기억함)에서 닉네임을 변경해봅니다.\n", - "Dependency Injection(`get_current_user`)이 토큰을 어떻게 해석해서 유저를 찾아내는지 체험합니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# 1. 현재 정보 확인 (@me)\n", - "me_res = await client.get(\"/account/@me\")\n", - "print(f\"변경 전 닉네임: {me_res.json()['display_name']}\")\n", - "\n", - "# 2. 정보 수정 요청 (PATCH)\n", - "update_data = {\"display_name\": \"AI마스터\"}\n", - "patch_res = await client.patch(\"/account/@me\", json=update_data)\n", - "\n", - "# 3. 변경 후 정보 확인\n", - "me_res_after = await client.get(\"/account/@me\")\n", - "print(f\"변경 후 닉네임: {me_res_after.json()['display_name']}\")\n", - "\n", - "assert me_res_after.json()['display_name'] == \"AI마스터\"\n", - "print(\"✅ 내 정보 수정 성공!\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 9. 🧹 테스트 리소스 정리\n", - "사용했던 자원을 정리합니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await client.aclose()\n", - "await test_engine.dispose()\n", - "print(\"✅ 테스트 종료 및 리소스 해제 완료\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 10. 🏛 데이터 모델링 & 마이그레이션 (Alembic)\n", - "\n", - "지금까지는 코드가 꺼지면 데이터가 사라지는 '메모리 DB'를 사용했습니다.\n", - "이제는 **파일로 남는 진짜 DB(local.db)**를 사용하고, 테이블 구조를 변경하는 실습을 해보겠습니다.\n", - "\n", - "### 실습 목표: `Booking` 테이블에 `memo` 필드 추가하기" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "from sqlmodel import Field, Text\n", - "\n", - "model_path = \"appserver/apps/calendar/models.py\"\n", - "\n", - "# 1. Booking 모델 코드에 새로운 필드('memo')를 삽입하는 파이썬 스크립트\n", - "# (마치 우리가 에디터에서 코드를 수정한 것처럼 파일 내용을 바꿉니다)\n", - "with open(model_path, \"r\", encoding=\"utf-8\") as f:\n", - " content = f.read()\n", - "\n", - "if \"memo: str = Field\" not in content:\n", - " target_string = 'description: str = Field(sa_type=Text, description=\"예약 설명\")'\n", - " new_field = '\\n memo: str = Field(default=\"\", description=\"메모\")'\n", - " \n", - " new_content = content.replace(target_string, target_string + new_field)\n", - " \n", - " with open(model_path, \"w\", encoding=\"utf-8\") as f:\n", - " f.write(new_content)\n", - " print(\"✅ models.py 파일에 'memo' 필드가 추가되었습니다!\")\n", - "else:\n", - " print(\"ℹ️ 이미 'memo' 필드로 보이는 코드가 존재합니다.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 11. 📦 마이그레이션 명령어 실행\n", - "\n", - "코드가 바뀌었으니, 이를 DB에 알리는 `Alembic` 명령어를 실행합니다.\n", - "노트북에서 `!`를 앞에 붙이면 터미널 명령어를 실행할 수 있습니다." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# 1. 기존 테이블 최신화 (혹시 안 된 것이 있다면)\n", - "!alembic upgrade head\n", - "\n", - "# 2. 새로운 변경사항(리비전) 생성\n", - "# --autogenerate: 코드를 보고 바뀐 점을 자동으로 찾아라\n", - "!alembic revision --autogenerate -m \"Add memo field to booking\"\n", - "\n", - "# 3. DB에 반영 (테이블 컬럼 추가)\n", - "!alembic upgrade head" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 12. ✅ 체크포인트: DB 파일 확인\n", - "\n", - "이제 프로젝트 폴더에 `local.db` 파일이 생겼거나 업데이트되었을 것입니다.\n", - "이로써 여러분은 메모리 DB, 가상 클라이언트, 그리고 실제 DB 마이그레이션까지 **백엔드 개발의 전 과정**을 경험했습니다.\n", - "\n", - "수고하셨습니다! 🎉" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.0" - } - }, - "nbformat": 4, - "nbformat_minor": 2 +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 📘 FastAPI 완전 정복 노트\n", + "\n", + "이 노트북은 여러분이 작성한 모든 코드를 직접 실행해보고 검증할 수 있는 통합 실습 환경입니다.\n", + "DB 생성부터 API 호출, 보안 검증, 그리고 마이그레이션까지 순서대로 진행하며 전체 흐름을 익혀봅시다!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 🛠 환경 설정 (Imports & Setup)\n", + "\n", + "프로젝트의 모든 모듈을 불러올 수 있도록 경로를 설정하고 필요한 라이브러리를 임포트합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import os\n", + "from typing import AsyncGenerator\n", + "\n", + "# 현재 프로젝트 루트를 파이썬 경로에 추가\n", + "current_dir = os.getcwd()\n", + "if current_dir not in sys.path:\n", + " sys.path.append(current_dir)\n", + "\n", + "import asyncio\n", + "import nest_asyncio\n", + "nest_asyncio.apply()\n", + "\n", + "import pytest\n", + "from httpx import AsyncClient, ASGITransport\n", + "from sqlmodel import SQLModel, select\n", + "from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession\n", + "from sqlalchemy.orm import sessionmaker\n", + "\n", + "# FastAPI 앱 및 모델 임포트\n", + "from fastapi import FastAPI\n", + "from appserver.app import include_routers\n", + "from appserver.db import use_session\n", + "from appserver.apps.account.models import User\n", + "from appserver.apps.account.schemas import UserCreatePayload, LoginPayload\n", + "from appserver.apps.account.utils import hash_password, verify_password\n", + "from dataclasses import dataclass\n", + "\n", + "print(\"✅ 모든 라이브러리 임포트 완료!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 🗄 데이터베이스 구축 (In-Memory DB)\n", + "\n", + "실제 서버를 띄우지 않고, 노트북 안에서만 사용할 \"가짜 DB(메모리 DB)\"를 만듭니다.\n", + "이 단계가 성공해야 데이터를 저장하고 불러올 수 있습니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 비동기 엔진 생성 (메모리 DB)\n", + "test_engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n", + "\n", + "async def init_db():\n", + " async with test_engine.begin() as conn:\n", + " # 모든 테이블 생성 (User 테이블 등)\n", + " await conn.run_sync(SQLModel.metadata.create_all)\n", + " print(\"✅ 데이터베이스 테이블 생성 완료!\")\n", + "\n", + "# 주피터는 이미 비동기 루프가 돌고 있으므로 await로 바로 실행\n", + "await init_db()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 🛡 로직 단위 테스트: 비밀번호 해싱\n", + "\n", + "`appserver/apps/account/utils.py`의 핵심 보안 기능을 먼저 검증합니다.\n", + "내가 입력한 비밀번호가 DB에 어떻게 저장되는지 눈으로 확인하세요." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "raw_pw = \"ilovepython\"\n", + "hashed_pw = hash_password(raw_pw)\n", + "\n", + "print(f\"🔑 원본 비밀번호: {raw_pw}\")\n", + "print(f\"🔒 해시된 비밀번호: {hashed_pw}\")\n", + "\n", + "# 검증 테스트\n", + "assert verify_password(raw_pw, hashed_pw) == True, \"비밀번호 검증 실패!\"\n", + "assert verify_password(\"wrongpw\", hashed_pw) == False, \"틀린 비밀번호가 통과됨!\"\n", + "print(\"✅ 비밀번호 보안 로직 정상 작동!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 🌐 가상 API 클라이언트 생성\n", + "\n", + "브라우저 없이 코드로 서버와 통신할 수 있는 `AsyncClient`를 만듭니다.\n", + "이 클라이언트가 여러분 대신 `/account/signup`, `/account/login` 등으로 요청을 보낼 겁니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. FastAPI 앱 인스턴스 생성\n", + "app = FastAPI()\n", + "include_routers(app)\n", + "\n", + "# 2. DB 의존성 오버라이드 (가짜 DB 사용하도록 연결)\n", + "async def override_use_session() -> AsyncGenerator[AsyncSession, None]:\n", + " async_session = sessionmaker(\n", + " test_engine, class_=AsyncSession, expire_on_commit=False\n", + " )\n", + " async with async_session() as session:\n", + " yield session\n", + "\n", + "app.dependency_overrides[use_session] = override_use_session\n", + "\n", + "# 3. 클라이언트 생성\n", + "client = AsyncClient(transport=ASGITransport(app=app), base_url=\"http://test\")\n", + "print(\"✅ 가상 API 클라이언트 준비 완료!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 🚀 실전 시나리오 1: 회원가입 (Sign Up)\n", + "\n", + "유저가 회원가입 폼을 작성해서 제출하는 상황을 시뮬레이션합니다.\n", + "직접 JSON 데이터를 만들어서 POST 요청을 보내봅니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "signup_data = {\n", + " \"username\": \"fastapi_master\",\n", + " \"password\": \"code1234\",\n", + " \"password_again\": \"code1234\",\n", + " \"email\": \"master@example.com\",\n", + " \"display_name\": \"파이썬고수\"\n", + "}\n", + "\n", + "response = await client.post(\"/account/signup\", json=signup_data)\n", + "\n", + "print(f\"📡 응답 상태 코드: {response.status_code}\")\n", + "print(f\"📄 응답 데이터: {response.json()}\")\n", + "\n", + "if response.status_code == 201:\n", + " print(\"✅ 회원가입 성공!\")\n", + "else:\n", + " print(\"❌ 회원가입 실패 (로그 확인 필요)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 🔍 DB 확인: 진짜 저장되었나요?\n", + "\n", + "API는 성공했다고 하지만, 진짜 DB에 데이터가 들어갔는지 의심스러우시죠?\n", + "직접 SELECT 쿼리를 날려서 확인해봅시다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)\n", + "\n", + "async with async_session() as session:\n", + " # SELECT * FROM user WHERE username = 'fastapi_master'\n", + " stmt = select(User).where(User.username == \"fastapi_master\")\n", + " result = await session.execute(stmt)\n", + " user = result.scalar_one_or_none()\n", + "\n", + " if user:\n", + " print(f\"✅ DB 조회 성공!\")\n", + " print(f\"ID: {user.id}\")\n", + " print(f\"Username: {user.username}\")\n", + " print(f\"Email: {user.email}\")\n", + " print(f\"Hashed Password: {user.hashed_password} (안전하게 암호화됨)\")\n", + " else:\n", + " print(\"❌ DB에 유저가 없습니다!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 🚀 실전 시나리오 2: 로그인 (Login)\n", + "\n", + "이제 회원가입한 아이디로 로그인을 시도합니다.\n", + "성공하면 **Access Token(신분증)**을 쿠키나 바디로 받아야 합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "login_data = {\n", + " \"username\": \"fastapi_master\",\n", + " \"password\": \"code1234\"\n", + "}\n", + "\n", + "response = await client.post(\"/account/login\", json=login_data)\n", + "\n", + "print(f\"📡 응답 상태 코드: {response.status_code}\")\n", + "print(f\"📄 응답 바디: {response.json()}\")\n", + "\n", + "# 쿠키 확인\n", + "cookies = response.cookies\n", + "auth_token = cookies.get(\"auth_token\")\n", + "\n", + "if auth_token:\n", + " print(f\"\\n🍪 발급된 쿠키(auth_token): {auth_token[:20]}... (생략)\")\n", + " print(\"✅ 로그인 및 토큰 발급 성공!\")\n", + "else:\n", + " print(\"❌ 쿠키가 없습니다. 로그인 로직을 점검하세요.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. 🚀 실전 시나리오 3: 내 정보 수정 (Update)\n", + "\n", + "로그인 상태(`client`가 쿠기를 기억함)에서 닉네임을 변경해봅니다.\n", + "Dependency Injection(`get_current_user`)이 토큰을 어떻게 해석해서 유저를 찾아내는지 체험합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. 현재 정보 확인 (@me)\n", + "me_res = await client.get(\"/account/@me\")\n", + "print(f\"변경 전 닉네임: {me_res.json()['display_name']}\")\n", + "\n", + "# 2. 정보 수정 요청 (PATCH)\n", + "update_data = {\"display_name\": \"AI마스터\"}\n", + "patch_res = await client.patch(\"/account/@me\", json=update_data)\n", + "\n", + "# 3. 변경 후 정보 확인\n", + "me_res_after = await client.get(\"/account/@me\")\n", + "print(f\"변경 후 닉네임: {me_res_after.json()['display_name']}\")\n", + "\n", + "assert me_res_after.json()['display_name'] == \"AI마스터\"\n", + "print(\"✅ 내 정보 수정 성공!\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 9. 🧹 테스트 리소스 정리\n", + "사용했던 자원을 정리합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await client.aclose()\n", + "await test_engine.dispose()\n", + "print(\"✅ 테스트 종료 및 리소스 해제 완료\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 10. 🏛 데이터 모델링 & 마이그레이션 (Alembic)\n", + "\n", + "지금까지는 코드가 꺼지면 데이터가 사라지는 '메모리 DB'를 사용했습니다.\n", + "이제는 **파일로 남는 진짜 DB(local.db)**를 사용하고, 테이블 구조를 변경하는 실습을 해보겠습니다.\n", + "\n", + "### 실습 목표: `Booking` 테이블에 `memo` 필드 추가하기" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from sqlmodel import Field, Text\n", + "\n", + "model_path = \"appserver/apps/calendar/models.py\"\n", + "\n", + "# 1. Booking 모델 코드에 새로운 필드('memo')를 삽입하는 파이썬 스크립트\n", + "# (마치 우리가 에디터에서 코드를 수정한 것처럼 파일 내용을 바꿉니다)\n", + "with open(model_path, \"r\", encoding=\"utf-8\") as f:\n", + " content = f.read()\n", + "\n", + "if \"memo: str = Field\" not in content:\n", + " target_string = 'description: str = Field(sa_type=Text, description=\"예약 설명\")'\n", + " new_field = '\\n memo: str = Field(default=\"\", description=\"메모\")'\n", + " \n", + " new_content = content.replace(target_string, target_string + new_field)\n", + " \n", + " with open(model_path, \"w\", encoding=\"utf-8\") as f:\n", + " f.write(new_content)\n", + " print(\"✅ models.py 파일에 'memo' 필드가 추가되었습니다!\")\n", + "else:\n", + " print(\"ℹ️ 이미 'memo' 필드로 보이는 코드가 존재합니다.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 11. 📦 마이그레이션 명령어 실행\n", + "\n", + "코드가 바뀌었으니, 이를 DB에 알리는 `Alembic` 명령어를 실행합니다.\n", + "노트북에서 `!`를 앞에 붙이면 터미널 명령어를 실행할 수 있습니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. 기존 테이블 최신화 (혹시 안 된 것이 있다면)\n", + "!alembic upgrade head\n", + "\n", + "# 2. 새로운 변경사항(리비전) 생성\n", + "# --autogenerate: 코드를 보고 바뀐 점을 자동으로 찾아라\n", + "!alembic revision --autogenerate -m \"Add memo field to booking\"\n", + "\n", + "# 3. DB에 반영 (테이블 컬럼 추가)\n", + "!alembic upgrade head" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 12. ✅ 체크포인트: DB 파일 확인\n", + "\n", + "이제 프로젝트 폴더에 `local.db` 파일이 생겼거나 업데이트되었을 것입니다.\n", + "이로써 여러분은 메모리 DB, 가상 클라이언트, 그리고 실제 DB 마이그레이션까지 **백엔드 개발의 전 과정**을 경험했습니다.\n", + "\n", + "수고하셨습니다! 🎉" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 2 } \ No newline at end of file diff --git a/tests/apps/account/test_endpoints.py b/tests/apps/account/test_endpoints.py index bcf1b41..eb71e46 100644 --- a/tests/apps/account/test_endpoints.py +++ b/tests/apps/account/test_endpoints.py @@ -1,108 +1,108 @@ -from typing import TYPE_CHECKING -from fastapi import HTTPException -from appserver.apps.account.endpoints import user_detail -import pytest -from fastapi import HTTPException, status -from fastapi.testclient import TestClient -from appserver.app import app -from appserver.apps.account.models import User -from appserver.apps.calendar.models import Calendar -from sqlmodel import SQLModel -from sqlalchemy.ext.asyncio import AsyncSession -from appserver.db import create_async_engine, create_session - -async def test_user_detail_successfully(db_session: AsyncSession): - """정상적인 사용자 조회 시 데이터가 정확한지 확인""" - host_user = User( - username="test-hostuser", - password="test", - email="test.hostuser@example.com", - display_name="test", - is_host=True, - ) - db_session.add(host_user) - await db_session.commit() - await db_session.refresh(host_user) # DB에서 생성된 ID 등을 가져오기 - - # user_detail 함수 호출 - result = await user_detail(host_user.username, db_session) - - # 결과 검증 - assert result.username == "test-hostuser" - assert result.email == "test.hostuser@example.com" - assert result.display_name == "test" - assert result.is_host is True - - -async def test_user_detail_not_found(db_session: AsyncSession): - """사용자가 없을 때 HTTPException이 발생하는지 확인""" - # pytest.raises: 예외가 발생하는지 체크하는 컨텍스트 매니저 - with pytest.raises(HTTPException) as exc_info: - await user_detail("not_found", db_session) - # 발생한 에러의 상태 코드가 404인지 검증 - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND - - -def test_user_detail_by_http_not_found(client: TestClient): - """HTTP 클라이언트를 통해 실제 라우팅과 응답을 확인""" - # 실제 API 경로로 GET 요청 전송 - response = client.get("/account/users/not_found") - # HTTP 상태 코드 검증 - assert response.status_code == status.HTTP_404_NOT_FOUND - - -async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): - """Integration Test (E2E): 실제 HTTP 요청/응답 과정을 테스트""" - # 1. 먼저 테스트용 사용자를 DB에 생성 - user = User( - username="test-http-user", - password="test", - email="test-http@example.com", - display_name="HTTP Test User", - is_host=True, - ) - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - - # 2. HTTP 클라이언트로 사용자 조회 - response = client.get(f"/account/users/{user.username}") - - # 3. 응답 검증 - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["username"] == user.username - assert data["email"] == user.email - assert data["display_name"] == user.display_name - assert data["is_host"] is True - assert data["created_at"] is not None - assert data["updated_at"] is not None - - -async def test_user_detail_for_real_user(db_session: AsyncSession): - """실제 DB 세션을 사용하여 사용자 생성 후 조회 테스트""" - # 1. 사용자 생성 - user = User( - username="test-real-user", - password="test", - email="test-real@example.com", - display_name="Real Test User", - is_host=True, - ) - db_session.add(user) - await db_session.commit() - await db_session.refresh(user) - - # 2. user_detail 함수로 조회 - result = await user_detail(user.username, db_session) - - # 3. 검증 - assert result.username == user.username - assert result.email == user.email - assert result.display_name == user.display_name - assert result.is_host is True - - # 4. 존재하지 않는 사용자 조회 시 404 확인 - with pytest.raises(HTTPException) as exc_info: - await user_detail("not_found_user", db_session) - assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND +from typing import TYPE_CHECKING +from fastapi import HTTPException +from appserver.apps.account.endpoints import user_detail +import pytest +from fastapi import HTTPException, status +from fastapi.testclient import TestClient +from appserver.app import app +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from sqlmodel import SQLModel +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.db import create_async_engine, create_session + +async def test_user_detail_successfully(db_session: AsyncSession): + """정상적인 사용자 조회 시 데이터가 정확한지 확인""" + host_user = User( + username="test-hostuser", + password="test", + email="test.hostuser@example.com", + display_name="test", + is_host=True, + ) + db_session.add(host_user) + await db_session.commit() + await db_session.refresh(host_user) # DB에서 생성된 ID 등을 가져오기 + + # user_detail 함수 호출 + result = await user_detail(host_user.username, db_session) + + # 결과 검증 + assert result.username == "test-hostuser" + assert result.email == "test.hostuser@example.com" + assert result.display_name == "test" + assert result.is_host is True + + +async def test_user_detail_not_found(db_session: AsyncSession): + """사용자가 없을 때 HTTPException이 발생하는지 확인""" + # pytest.raises: 예외가 발생하는지 체크하는 컨텍스트 매니저 + with pytest.raises(HTTPException) as exc_info: + await user_detail("not_found", db_session) + # 발생한 에러의 상태 코드가 404인지 검증 + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + + +def test_user_detail_by_http_not_found(client: TestClient): + """HTTP 클라이언트를 통해 실제 라우팅과 응답을 확인""" + # 실제 API 경로로 GET 요청 전송 + response = client.get("/account/users/not_found") + # HTTP 상태 코드 검증 + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_user_detail_by_http(client: TestClient, db_session: AsyncSession): + """Integration Test (E2E): 실제 HTTP 요청/응답 과정을 테스트""" + # 1. 먼저 테스트용 사용자를 DB에 생성 + user = User( + username="test-http-user", + password="test", + email="test-http@example.com", + display_name="HTTP Test User", + is_host=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + + # 2. HTTP 클라이언트로 사용자 조회 + response = client.get(f"/account/users/{user.username}") + + # 3. 응답 검증 + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["username"] == user.username + assert data["email"] == user.email + assert data["display_name"] == user.display_name + assert data["is_host"] is True + assert data["created_at"] is not None + assert data["updated_at"] is not None + + +async def test_user_detail_for_real_user(db_session: AsyncSession): + """실제 DB 세션을 사용하여 사용자 생성 후 조회 테스트""" + # 1. 사용자 생성 + user = User( + username="test-real-user", + password="test", + email="test-real@example.com", + display_name="Real Test User", + is_host=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + + # 2. user_detail 함수로 조회 + result = await user_detail(user.username, db_session) + + # 3. 검증 + assert result.username == user.username + assert result.email == user.email + assert result.display_name == user.display_name + assert result.is_host is True + + # 4. 존재하지 않는 사용자 조회 시 404 확인 + with pytest.raises(HTTPException) as exc_info: + await user_detail("not_found_user", db_session) + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/apps/account/test_login_api.py b/tests/apps/account/test_login_api.py index 1f93d0c..0a9a1a0 100644 --- a/tests/apps/account/test_login_api.py +++ b/tests/apps/account/test_login_api.py @@ -1,23 +1,23 @@ -from fastapi import status -from fastapi.testclient import TestClient -from appserver.apps.account.schemas import LoginPayload -from appserver.apps.account.models import User - -async def test_로그인_성공(host_user: User, client: TestClient): - payload = LoginPayload.model_validate({ - "username": host_user.username, - "password": "testtest", - }) - - response = client.post("/account/login", json=payload.model_dump()) - assert response.status_code == status.HTTP_200_OK - - data = response.json() - assert data["access_token"] is not None - assert data["token_type"] == "bearer" - assert data["user"]["username"] == host_user.username - assert data["user"]["display_name"] == host_user.display_name - assert data["user"]["is_host"] == host_user.is_host - cookie = response.cookies.get("auth_token") - assert cookie is not None +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.schemas import LoginPayload +from appserver.apps.account.models import User + +async def test_로그인_성공(host_user: User, client: TestClient): + payload = LoginPayload.model_validate({ + "username": host_user.username, + "password": "testtest", + }) + + response = client.post("/account/login", json=payload.model_dump()) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["access_token"] is not None + assert data["token_type"] == "bearer" + assert data["user"]["username"] == host_user.username + assert data["user"]["display_name"] == host_user.display_name + assert data["user"]["is_host"] == host_user.is_host + cookie = response.cookies.get("auth_token") + assert cookie is not None assert cookie == data["access_token"] \ No newline at end of file diff --git a/tests/apps/account/test_logout_api.py b/tests/apps/account/test_logout_api.py index 4cb71c7..296f302 100644 --- a/tests/apps/account/test_logout_api.py +++ b/tests/apps/account/test_logout_api.py @@ -1,12 +1,12 @@ -from fastapi.testclient import TestClient -from fastapi import status -from appserver.apps.account.constants import AUTH_TOKEN_COOKIE_NAME -from appserver.apps.account.models import User - -async def test_로그아웃_시_인증_토큰이_삭제되어야_한다( - client_with_auth: TestClient, -): - response = client_with_auth.delete("/account/logout") - assert response.status_code == status.HTTP_200_OK - assert response.cookies.get(AUTH_TOKEN_COOKIE_NAME) is None - +from fastapi.testclient import TestClient +from fastapi import status +from appserver.apps.account.constants import AUTH_TOKEN_COOKIE_NAME +from appserver.apps.account.models import User + +async def test_로그아웃_시_인증_토큰이_삭제되어야_한다( + client_with_auth: TestClient, +): + response = client_with_auth.delete("/account/logout") + assert response.status_code == status.HTTP_200_OK + assert response.cookies.get(AUTH_TOKEN_COOKIE_NAME) is None + diff --git a/tests/apps/account/test_me_api.py b/tests/apps/account/test_me_api.py index a1ecd31..a8c0d1a 100644 --- a/tests/apps/account/test_me_api.py +++ b/tests/apps/account/test_me_api.py @@ -1,41 +1,41 @@ -from fastapi import status -from fastapi.testclient import TestClient -from appserver.apps.account.models import User -from appserver.apps.account.utils import decode_token, create_access_token -from datetime import datetime, timedelta, timezone - -def test_내_정보_조회(client_with_auth: TestClient, host_user: User): - response = client_with_auth.get("/account/@me") - data = response.json() - assert response.status_code == status.HTTP_200_OK - - response_keys = frozenset(data.keys()) - expected_keys = frozenset(["username", "display_name", "is_host", "email", "created_at", "updated_at"]) - assert response_keys == expected_keys - -def test_토큰이_없는_경우_의심스런_접근_오류를_일으킨다(client: TestClient): - response = client.get("/account/@me") - assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT - -def test_유효하지_않은_토큰인_경우_인증_오류를_일으킨다(client_with_auth: TestClient): - client_with_auth.cookies["auth_token"] = "invalid_token" - response = client_with_auth.get("/account/@me") - assert response.status_code == status.HTTP_401_UNAUTHORIZED - -def test_만료된_토큰으로_내_정보_조회(client_with_auth: TestClient): - token = client_with_auth.cookies.get("auth_token", domain="", path="/") - decoded = decode_token(token) - jwt = create_access_token(decoded, timedelta(hours=-1)) - client_with_auth.cookies["auth_token"] = jwt - - response = client_with_auth.get("/account/@me") - assert response.status_code == status.HTTP_401_UNAUTHORIZED - -def test_유저가_존재하지_않은_경우_내_정보_조회(client_with_auth: TestClient): - token = client_with_auth.cookies.get("auth_token", domain="", path="/") - decoded = decode_token(token) - decoded["sub"] = "invalid_user_id" - jwt = create_access_token(decoded) - client_with_auth.cookies["auth_token"] = jwt - response = client_with_auth.get("/account/@me") +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.models import User +from appserver.apps.account.utils import decode_token, create_access_token +from datetime import datetime, timedelta, timezone + +def test_내_정보_조회(client_with_auth: TestClient, host_user: User): + response = client_with_auth.get("/account/@me") + data = response.json() + assert response.status_code == status.HTTP_200_OK + + response_keys = frozenset(data.keys()) + expected_keys = frozenset(["username", "display_name", "is_host", "email", "created_at", "updated_at"]) + assert response_keys == expected_keys + +def test_토큰이_없는_경우_의심스런_접근_오류를_일으킨다(client: TestClient): + response = client.get("/account/@me") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + +def test_유효하지_않은_토큰인_경우_인증_오류를_일으킨다(client_with_auth: TestClient): + client_with_auth.cookies["auth_token"] = "invalid_token" + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_만료된_토큰으로_내_정보_조회(client_with_auth: TestClient): + token = client_with_auth.cookies.get("auth_token", domain="", path="/") + decoded = decode_token(token) + jwt = create_access_token(decoded, timedelta(hours=-1)) + client_with_auth.cookies["auth_token"] = jwt + + response = client_with_auth.get("/account/@me") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +def test_유저가_존재하지_않은_경우_내_정보_조회(client_with_auth: TestClient): + token = client_with_auth.cookies.get("auth_token", domain="", path="/") + decoded = decode_token(token) + decoded["sub"] = "invalid_user_id" + jwt = create_access_token(decoded) + client_with_auth.cookies["auth_token"] = jwt + response = client_with_auth.get("/account/@me") assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/tests/apps/account/test_signup.py b/tests/apps/account/test_signup.py index 2992be9..2195e74 100644 --- a/tests/apps/account/test_signup.py +++ b/tests/apps/account/test_signup.py @@ -1,86 +1,86 @@ -from sqlalchemy.ext.asyncio import AsyncSession -from appserver.apps.account.endpoints import signup -from appserver.apps.account.models import User -from fastapi.testclient import TestClient -import pytest -from pydantic import ValidationError -from appserver.apps.account.exceptions import DuplicatedUsernameError, DuplicatedEmailError - -async def test_모든_입력_항목을_유효한_값으로_입력하면_계정이_생성된다( - client: TestClient, - db_session: AsyncSession - ): - payload = { - "username": "test", - "email": "test@example.com", - "display_name": "test", - "password": "test테스트1234", - } - - result = await signup(payload, db_session) - - assert isinstance(result, User) - assert result.username == payload["username"] - assert result.email == payload["email"] - assert result.display_name == payload["display_name"] - assert result.is_host is False - -@pytest.mark.parametrize( - "username", - [ - "puddingcamppuddingcamppuddingcamppuddingcamppuddingcamp", - 12345678, - "x", - ] -) - -async def test_사용자명이_유효하지_않으면_사용자명이_유효하지_않다는_메세지를_담은_오류를_일으킨다( - client: TestClient, - db_session: AsyncSession, - username: str -): - payload = { - "username": username, - "email": "test@example.com", - "display_name": "test", - "password": "test테스트1234", - } - - with pytest.raises(ValidationError) as exc_info: - await signup(payload, db_session) - -async def test_계정_ID가_중복되면_중복_계정_ID_오류를_일으킨다(db_session: AsyncSession): - payload = { - "username" : "test", - "email" : "test@example.com", - "display_name": "test", - "password": "test테스트1234", - } - await signup(payload, db_session) - - payload["username"] = "test2" - with pytest.raises(DuplicatedUsernameError) as exc: - await signup(payload, db_session) - -async def test_e_mail_주소가_중복되면_중복_이메일_오류를_일으킨다(db_session: AsyncSession): - payload = { - "username": "test", - "email": "test@example.com", - "display_name": "test", - "password": "test테스트1234", - } - await signup(payload, db_session) - - payload["username"] = "test2" - with pytest.raises(DuplicatedEmailError) as exc: - await signup(payload, db_session) - -async def test_표시명을_입력하지_않으면_무작위_문자열_8글자로_대신한다(db_session: AsyncSession): - payload = { - "username": "test", - "email": "test@example.com", - "password": "test테스트1234", - } - user = await signup(payload, db_session) - assert isinstance(user.display_name, str) - assert len(user.display_name) == 8 +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.apps.account.endpoints import signup +from appserver.apps.account.models import User +from fastapi.testclient import TestClient +import pytest +from pydantic import ValidationError +from appserver.apps.account.exceptions import DuplicatedUsernameError, DuplicatedEmailError + +async def test_모든_입력_항목을_유효한_값으로_입력하면_계정이_생성된다( + client: TestClient, + db_session: AsyncSession + ): + payload = { + "username": "test", + "email": "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + + result = await signup(payload, db_session) + + assert isinstance(result, User) + assert result.username == payload["username"] + assert result.email == payload["email"] + assert result.display_name == payload["display_name"] + assert result.is_host is False + +@pytest.mark.parametrize( + "username", + [ + "puddingcamppuddingcamppuddingcamppuddingcamppuddingcamp", + 12345678, + "x", + ] +) + +async def test_사용자명이_유효하지_않으면_사용자명이_유효하지_않다는_메세지를_담은_오류를_일으킨다( + client: TestClient, + db_session: AsyncSession, + username: str +): + payload = { + "username": username, + "email": "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + + with pytest.raises(ValidationError) as exc_info: + await signup(payload, db_session) + +async def test_계정_ID가_중복되면_중복_계정_ID_오류를_일으킨다(db_session: AsyncSession): + payload = { + "username" : "test", + "email" : "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + await signup(payload, db_session) + + payload["username"] = "test2" + with pytest.raises(DuplicatedUsernameError) as exc: + await signup(payload, db_session) + +async def test_e_mail_주소가_중복되면_중복_이메일_오류를_일으킨다(db_session: AsyncSession): + payload = { + "username": "test", + "email": "test@example.com", + "display_name": "test", + "password": "test테스트1234", + } + await signup(payload, db_session) + + payload["username"] = "test2" + with pytest.raises(DuplicatedEmailError) as exc: + await signup(payload, db_session) + +async def test_표시명을_입력하지_않으면_무작위_문자열_8글자로_대신한다(db_session: AsyncSession): + payload = { + "username": "test", + "email": "test@example.com", + "password": "test테스트1234", + } + user = await signup(payload, db_session) + assert isinstance(user.display_name, str) + assert len(user.display_name) == 8 diff --git a/tests/apps/account/test_signup_api.py b/tests/apps/account/test_signup_api.py index b754678..9518686 100644 --- a/tests/apps/account/test_signup_api.py +++ b/tests/apps/account/test_signup_api.py @@ -1,36 +1,36 @@ -from fastapi import status -from fastapi.testclient import TestClient - -async def test_회원가입_성공(client: TestClient): - payload = { - "username": "test", - "email": "test@example.com", - "password": "test테스트1234", - "password_again": "test테스트1234", - } - - response = client.post("/account/signup", json=payload) - - data = response.json() - assert response.status_code == status.HTTP_201_CREATED - assert data["username"] == payload["username"] - assert data["email"] == payload["email"] - assert isinstance(data["display_name"], str) - assert len(data["display_name"]) == 8 - -async def test_응답_결과에는_username_display_name_is_host_만_출력환다(client: TestClient): - payload = { - "username": "puddingcamp", - "display_name": "푸딩캠프", - "email": "test@example.com", - "password": "test테스트1234", - "password_again": "test테스트1234", - } - - response = client.post("/account/signup", json=payload) - data = response.json() - assert response.status_code == status.HTTP_201_CREATED - - response_keys = frozenset(data.keys()) - expected_keys = frozenset(["username", "display_name", "is_host"]) +from fastapi import status +from fastapi.testclient import TestClient + +async def test_회원가입_성공(client: TestClient): + payload = { + "username": "test", + "email": "test@example.com", + "password": "test테스트1234", + "password_again": "test테스트1234", + } + + response = client.post("/account/signup", json=payload) + + data = response.json() + assert response.status_code == status.HTTP_201_CREATED + assert data["username"] == payload["username"] + assert data["email"] == payload["email"] + assert isinstance(data["display_name"], str) + assert len(data["display_name"]) == 8 + +async def test_응답_결과에는_username_display_name_is_host_만_출력환다(client: TestClient): + payload = { + "username": "puddingcamp", + "display_name": "푸딩캠프", + "email": "test@example.com", + "password": "test테스트1234", + "password_again": "test테스트1234", + } + + response = client.post("/account/signup", json=payload) + data = response.json() + assert response.status_code == status.HTTP_201_CREATED + + response_keys = frozenset(data.keys()) + expected_keys = frozenset(["username", "display_name", "is_host"]) assert response_keys == expected_keys \ No newline at end of file diff --git a/tests/apps/account/test_unregister_api.py b/tests/apps/account/test_unregister_api.py index 386a035..a7603f8 100644 --- a/tests/apps/account/test_unregister_api.py +++ b/tests/apps/account/test_unregister_api.py @@ -1,21 +1,21 @@ -from sqlalchemy.ext.asyncio import AsyncSession -from fastapi.testclient import TestClient -from fastapi import status -from appserver.apps.account.models import User - -async def test_회원탈퇴_시_유저가_삭제되어야_한다( - client_with_auth: TestClient, - host_user: User, - db_session: AsyncSession, -): - user_id = host_user.id - - assert await db_session.get(User, user_id) is not None - response = client_with_auth.delete("/account/unregister") - - assert response.status_code == status.HTTP_204_NO_CONTENT - # Clear identity map so we don't read cached instance from another session. - db_session.expire_all() - assert await db_session.get(User, user_id) is None - - +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi.testclient import TestClient +from fastapi import status +from appserver.apps.account.models import User + +async def test_회원탈퇴_시_유저가_삭제되어야_한다( + client_with_auth: TestClient, + host_user: User, + db_session: AsyncSession, +): + user_id = host_user.id + + assert await db_session.get(User, user_id) is not None + response = client_with_auth.delete("/account/unregister") + + assert response.status_code == status.HTTP_204_NO_CONTENT + # Clear identity map so we don't read cached instance from another session. + db_session.expire_all() + assert await db_session.get(User, user_id) is None + + diff --git a/tests/apps/account/test_update_user_api.py b/tests/apps/account/test_update_user_api.py index 7ed6ffe..e8fd646 100644 --- a/tests/apps/account/test_update_user_api.py +++ b/tests/apps/account/test_update_user_api.py @@ -1,55 +1,55 @@ -import pytest -from fastapi import status -from fastapi.testclient import TestClient -from appserver.apps.account.models import User -from sqlalchemy.ext.asyncio import AsyncSession - -UPDATABLE_FIELDS = frozenset(["display_name", "email"]) - -@pytest.mark.parametrize("payload", [ - {"display_name": "푸딩캠프"}, - {"email": "test@example.com"}, - {"display_name": "푸딩캠프", "email": "test@example.com"}, -]) -async def test_사용자가_변경하는_항목만_변경되고_나머지는_기존_값을_유지한다( - client_with_auth: TestClient, - payload: dict, - host_user: User -): - # 현재 사용자 정보를 보관한다. - before_data = host_user.model_dump() - response = client_with_auth.patch("/account/@me", json=payload) - assert response.status_code == status.HTTP_200_OK - data = response.json() - - # 변경된 항목은 변경된 값으로 변경되어야 한다. - for key, value in payload.items(): - assert data[key] == value - - # 변경되지 않은 항목은 기존 값을 유지한다. - for key in UPDATABLE_FIELDS - frozenset(payload.keys()): - assert data[key] == before_data[key] - -async def test_최소_하나_이상_항목을_변경해야_하며_그렇지_않으면_오류를_일으킨다( - client_with_auth: TestClient, -): - response = client_with_auth.patch("/account/@me", json={}) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - -async def test_비밀번호_변경_시_해싱_처리한_비밀번호가_저장되어야_한다( - client_with_auth: TestClient, - host_user: User, - db_session: AsyncSession, -): - before_password = host_user.hashed_password - payload = { - "password": "new_password", - "password_again": "new_password", - } - - response = client_with_auth.patch("/account/@me", json=payload) - assert response.status_code == status.HTTP_200_OK - - await db_session.refresh(host_user) - assert host_user.hashed_password != before_password - +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from appserver.apps.account.models import User +from sqlalchemy.ext.asyncio import AsyncSession + +UPDATABLE_FIELDS = frozenset(["display_name", "email"]) + +@pytest.mark.parametrize("payload", [ + {"display_name": "푸딩캠프"}, + {"email": "test@example.com"}, + {"display_name": "푸딩캠프", "email": "test@example.com"}, +]) +async def test_사용자가_변경하는_항목만_변경되고_나머지는_기존_값을_유지한다( + client_with_auth: TestClient, + payload: dict, + host_user: User +): + # 현재 사용자 정보를 보관한다. + before_data = host_user.model_dump() + response = client_with_auth.patch("/account/@me", json=payload) + assert response.status_code == status.HTTP_200_OK + data = response.json() + + # 변경된 항목은 변경된 값으로 변경되어야 한다. + for key, value in payload.items(): + assert data[key] == value + + # 변경되지 않은 항목은 기존 값을 유지한다. + for key in UPDATABLE_FIELDS - frozenset(payload.keys()): + assert data[key] == before_data[key] + +async def test_최소_하나_이상_항목을_변경해야_하며_그렇지_않으면_오류를_일으킨다( + client_with_auth: TestClient, +): + response = client_with_auth.patch("/account/@me", json={}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + +async def test_비밀번호_변경_시_해싱_처리한_비밀번호가_저장되어야_한다( + client_with_auth: TestClient, + host_user: User, + db_session: AsyncSession, +): + before_password = host_user.hashed_password + payload = { + "password": "new_password", + "password_again": "new_password", + } + + response = client_with_auth.patch("/account/@me", json=payload) + assert response.status_code == status.HTTP_200_OK + + await db_session.refresh(host_user) + assert host_user.hashed_password != before_password + diff --git a/tests/apps/calendar/test_calendar.py b/tests/apps/calendar/test_calendar.py index 9136d5f..66a6fed 100644 --- a/tests/apps/calendar/test_calendar.py +++ b/tests/apps/calendar/test_calendar.py @@ -1,55 +1,55 @@ -import pytest -from sqlalchemy.ext.asyncio import AsyncSession -from appserver.apps.account.models import User -from appserver.apps.calendar.models import Calendar -from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut -from appserver.apps.calendar.endpoints import host_calendar_detail -from appserver.apps.calendar.exceptions import HostNotFoundError -from appserver.apps.calendar.exceptions import CalendarNotFoundError - -@pytest.mark.parametrize("user_key, expected_type", [ - ("host_user", CalendarDetailOut), - ("guest_user", CalendarOut), - (None, CalendarOut), -]) -async def test_호스트인_사용자의_username으로_캘린더_정보를_가져온다( - user_key: str | None, - expected_type: type[CalendarOut | CalendarDetailOut], - host_user: User, - host_user_calendar: Calendar, - guest_user: User, - db_session: AsyncSession, -): - users = { - "host_user": host_user, - "guest_user": guest_user, - None: None, - } - user = users[user_key] - - result = await host_calendar_detail(host_user.username, user, db_session) - - assert isinstance(result, expected_type) - result_keys = frozenset(result.model_dump().keys()) - expected_keys = frozenset(expected_type.model_fields.keys()) - assert result_keys == expected_keys - - assert result.topics == host_user_calendar.topics - assert result.description == host_user_calendar.description - if expected_type is CalendarDetailOut: - assert result.google_calendar_id == host_user_calendar.google_calendar_id - - -async def test_존재하지_않는_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( - db_session: AsyncSession, -) -> None: - with pytest.raises(HostNotFoundError): - await host_calendar_detail("not_exist_user", None, db_session) - - -async def test_호스트가_아닌_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( - guest_user: User, - db_session: AsyncSession, -) -> None: - with pytest.raises(CalendarNotFoundError): +import pytest +from sqlalchemy.ext.asyncio import AsyncSession +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut +from appserver.apps.calendar.endpoints import host_calendar_detail +from appserver.apps.calendar.exceptions import HostNotFoundError +from appserver.apps.calendar.exceptions import CalendarNotFoundError + +@pytest.mark.parametrize("user_key, expected_type", [ + ("host_user", CalendarDetailOut), + ("guest_user", CalendarOut), + (None, CalendarOut), +]) +async def test_호스트인_사용자의_username으로_캘린더_정보를_가져온다( + user_key: str | None, + expected_type: type[CalendarOut | CalendarDetailOut], + host_user: User, + host_user_calendar: Calendar, + guest_user: User, + db_session: AsyncSession, +): + users = { + "host_user": host_user, + "guest_user": guest_user, + None: None, + } + user = users[user_key] + + result = await host_calendar_detail(host_user.username, user, db_session) + + assert isinstance(result, expected_type) + result_keys = frozenset(result.model_dump().keys()) + expected_keys = frozenset(expected_type.model_fields.keys()) + assert result_keys == expected_keys + + assert result.topics == host_user_calendar.topics + assert result.description == host_user_calendar.description + if expected_type is CalendarDetailOut: + assert result.google_calendar_id == host_user_calendar.google_calendar_id + + +async def test_존재하지_않는_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + db_session: AsyncSession, +) -> None: + with pytest.raises(HostNotFoundError): + await host_calendar_detail("not_exist_user", None, db_session) + + +async def test_호스트가_아닌_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + guest_user: User, + db_session: AsyncSession, +) -> None: + with pytest.raises(CalendarNotFoundError): await host_calendar_detail(guest_user.username, None, db_session) \ No newline at end of file diff --git a/tests/apps/calendar/test_calendar_api.py b/tests/apps/calendar/test_calendar_api.py index 80ef46f..9c96a51 100644 --- a/tests/apps/calendar/test_calendar_api.py +++ b/tests/apps/calendar/test_calendar_api.py @@ -1,53 +1,53 @@ -from fastapi import status -from fastapi.testclient import TestClient -import pytest -from appserver.apps.account.models import User -from appserver.apps.calendar.models import Calendar -from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut -from appserver.apps.calendar.endpoints import host_calendar_detail - -@pytest.mark.parametrize("user_key, expected_type", [ - ("host_user", CalendarDetailOut), - ("guest_user", CalendarOut), - (None, CalendarOut), -]) -async def test_호스트인_사용자의_username_으로_캘린더_정보를_가져온다( - user_key: str | None, - expected_type: type[CalendarOut | CalendarDetailOut], - host_user: User, - host_user_calendar: Calendar, - client: TestClient, - client_with_auth: TestClient, -) -> CalendarOut | CalendarDetailOut: - clients = { - "host_user": client_with_auth, - "guest_user": client, - None: client, - } - user_client = clients[user_key] - - response = user_client.get(f"/calendar/{host_user.username}") - result = response.json() - assert response.status_code == status.HTTP_200_OK - - expected_obj = expected_tpye.model_validate(result) - - assert expected_obj.topics == host_user_calendar.topics - assert expected_obj.description == host_user_calendar.description - if expected_type is CalendarDetailOut: - assert expected_obj.google_calendar_id == host_user_calendar.google_calendar_id - - -async def test_존재하지_않는_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( - client: TestClient, -) -> None: - response = client.get("/calendar/not_exist_user") - assert response.status_code == status.HTTP_404_NOT_FOUND - - -async def test_호스트가_아닌_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( - guest_user: User, - client: TestClient, -) -> None: - response = client.get(f"/calendar/{guest_user.username}") - assert response.status_code == status.HTTP_404_NOT_FOUND +from fastapi import status +from fastapi.testclient import TestClient +import pytest +from appserver.apps.account.models import User +from appserver.apps.calendar.models import Calendar +from appserver.apps.calendar.schemas import CalendarDetailOut, CalendarOut +from appserver.apps.calendar.endpoints import host_calendar_detail + +@pytest.mark.parametrize("user_key, expected_type", [ + ("host_user", CalendarDetailOut), + ("guest_user", CalendarOut), + (None, CalendarOut), +]) +async def test_호스트인_사용자의_username_으로_캘린더_정보를_가져온다( + user_key: str | None, + expected_type: type[CalendarOut | CalendarDetailOut], + host_user: User, + host_user_calendar: Calendar, + client: TestClient, + client_with_auth: TestClient, +) -> CalendarOut | CalendarDetailOut: + clients = { + "host_user": client_with_auth, + "guest_user": client, + None: client, + } + user_client = clients[user_key] + + response = user_client.get(f"/calendar/{host_user.username}") + result = response.json() + assert response.status_code == status.HTTP_200_OK + + expected_obj = expected_tpye.model_validate(result) + + assert expected_obj.topics == host_user_calendar.topics + assert expected_obj.description == host_user_calendar.description + if expected_type is CalendarDetailOut: + assert expected_obj.google_calendar_id == host_user_calendar.google_calendar_id + + +async def test_존재하지_않는_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + client: TestClient, +) -> None: + response = client.get("/calendar/not_exist_user") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_호스트가_아닌_사용자의_username_으로_캘린더_정보를_가져오려_하면_404_응답을_반환한다( + guest_user: User, + client: TestClient, +) -> None: + response = client.get(f"/calendar/{guest_user.username}") + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/conftest.py b/tests/conftest.py index 4f0ea6c..7d590dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,129 +1,129 @@ -import pytest -import asyncio -from appserver.db import create_async_engine, create_session -# 모델들이 metadata에 등록되도록 반드시 import -from appserver.apps.account import models as account_models -from appserver.apps.calendar import models as calendar_models # ensure Calendar model is registered -from sqlmodel import SQLModel -from fastapi import FastAPI, status -from appserver.apps.account.schemas import LoginPayload -from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine -from appserver.db import create_engine, create_session, use_session -from appserver.app import include_routers -from fastapi.testclient import TestClient -from appserver.apps.account.utils import hash_password - -@pytest.fixture(scope="function") -async def db_engine(): - """테스트용 비동기 엔진을 생성하는 fixture""" - dsn = "sqlite+aiosqlite:///:memory:" - engine = create_async_engine(dsn) - - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - - yield engine - - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.drop_all) - - await engine.dispose() - - -@pytest.fixture(scope="function") -async def db_session(db_engine: AsyncEngine): - """테스트마다 독립된 트랜잭션을 제공하는 세션 fixture""" - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - await session.rollback() - - -@pytest.fixture() -def fastapi_app(db_engine: AsyncEngine): - """ - 테스트용 FastAPI 애플리케이션 인스턴스를 생성하는 fixture입니다. - 실제 DB 세션 생성 로직을 테스트용 세션으로 교체합니다. - """ - # 1. 테스트를 위한 독립적인 FastAPI 객체 생성 - app = FastAPI() - - # 2. 정의된 모든 API 경로(Router)를 앱에 등록 - include_routers(app) - - # 3. 의존성 오버라이드 함수 - # TestClient는 동기이므로, 비동기 세션을 동기적으로 사용할 수 있도록 해야 함 - async def override_use_session(): - session_factory = create_session(db_engine) - async with session_factory() as session: - yield session - - app.dependency_overrides[use_session] = override_use_session - return app - - -@pytest.fixture() -def client(fastapi_app: FastAPI): - """ - 테스트용 HTTP 클라이언트를 생성하는 fixture입니다. - 생성된 fastapi_app(의존성이 교체된 앱)을 실행하여 요청을 보낼 준비를 합니다. - """ - # TestClient는 내부적으로 비동기 함수를 동기적으로 실행해줍니다 - with TestClient(fastapi_app) as client: - yield client - -@pytest.fixture() -async def host_user(db_session: AsyncSession): - """ - 테스트용 호스트 사용자 fixture - """ - user = account_models.User( - username="puddingcamp", - hashed_password=hash_password("testtest"), - password=hash_password("testtest"), - email="puddingcamp@example.com", - display_name="푸딩캠프", - is_host=True, - ) - db_session.add(user) - await db_session.commit() - await db_session.flush(user) - return user - -@pytest.fixture() -def client_with_auth(fastapi_app: FastAPI, host_user: account_models.User): - """ - host_user로 로그인한 상태의 TestClient를 반환한다. (auth_token 쿠키 포함) - """ - payload = LoginPayload( - username=host_user.username, - password="testtest", - ) - - with TestClient(fastapi_app) as client: - response = client.post("/account/login", json=payload.model_dump()) - assert response.status_code == status.HTTP_200_OK - - auth_token = response.cookies.get("auth_token") - assert auth_token is not None - - client.cookies["auth_token"] = auth_token - yield client - -@pytest.fixture() -async def guest_user(db_session: AsyncSession): - user = account_models.User( - username = "puddingcafe", - hashed_password=hash_password("testtest"), - email="puddingcafe@example.com", - display_name="푸딩카페", - is_host=False, - ) - db_session.add(user) - await db_session.commit() - await db_session.flush() - return user - +import pytest +import asyncio +from appserver.db import create_async_engine, create_session +# 모델들이 metadata에 등록되도록 반드시 import +from appserver.apps.account import models as account_models +from appserver.apps.calendar import models as calendar_models # ensure Calendar model is registered +from sqlmodel import SQLModel +from fastapi import FastAPI, status +from appserver.apps.account.schemas import LoginPayload +from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine +from appserver.db import create_engine, create_session, use_session +from appserver.app import include_routers +from fastapi.testclient import TestClient +from appserver.apps.account.utils import hash_password + +@pytest.fixture(scope="function") +async def db_engine(): + """테스트용 비동기 엔진을 생성하는 fixture""" + dsn = "sqlite+aiosqlite:///:memory:" + engine = create_async_engine(dsn) + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + yield engine + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + + await engine.dispose() + + +@pytest.fixture(scope="function") +async def db_session(db_engine: AsyncEngine): + """테스트마다 독립된 트랜잭션을 제공하는 세션 fixture""" + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + await session.rollback() + + +@pytest.fixture() +def fastapi_app(db_engine: AsyncEngine): + """ + 테스트용 FastAPI 애플리케이션 인스턴스를 생성하는 fixture입니다. + 실제 DB 세션 생성 로직을 테스트용 세션으로 교체합니다. + """ + # 1. 테스트를 위한 독립적인 FastAPI 객체 생성 + app = FastAPI() + + # 2. 정의된 모든 API 경로(Router)를 앱에 등록 + include_routers(app) + + # 3. 의존성 오버라이드 함수 + # TestClient는 동기이므로, 비동기 세션을 동기적으로 사용할 수 있도록 해야 함 + async def override_use_session(): + session_factory = create_session(db_engine) + async with session_factory() as session: + yield session + + app.dependency_overrides[use_session] = override_use_session + return app + + +@pytest.fixture() +def client(fastapi_app: FastAPI): + """ + 테스트용 HTTP 클라이언트를 생성하는 fixture입니다. + 생성된 fastapi_app(의존성이 교체된 앱)을 실행하여 요청을 보낼 준비를 합니다. + """ + # TestClient는 내부적으로 비동기 함수를 동기적으로 실행해줍니다 + with TestClient(fastapi_app) as client: + yield client + +@pytest.fixture() +async def host_user(db_session: AsyncSession): + """ + 테스트용 호스트 사용자 fixture + """ + user = account_models.User( + username="puddingcamp", + hashed_password=hash_password("testtest"), + password=hash_password("testtest"), + email="puddingcamp@example.com", + display_name="푸딩캠프", + is_host=True, + ) + db_session.add(user) + await db_session.commit() + await db_session.flush(user) + return user + +@pytest.fixture() +def client_with_auth(fastapi_app: FastAPI, host_user: account_models.User): + """ + host_user로 로그인한 상태의 TestClient를 반환한다. (auth_token 쿠키 포함) + """ + payload = LoginPayload( + username=host_user.username, + password="testtest", + ) + + with TestClient(fastapi_app) as client: + response = client.post("/account/login", json=payload.model_dump()) + assert response.status_code == status.HTTP_200_OK + + auth_token = response.cookies.get("auth_token") + assert auth_token is not None + + client.cookies["auth_token"] = auth_token + yield client + +@pytest.fixture() +async def guest_user(db_session: AsyncSession): + user = account_models.User( + username = "puddingcafe", + hashed_password=hash_password("testtest"), + email="puddingcafe@example.com", + display_name="푸딩카페", + is_host=False, + ) + db_session.add(user) + await db_session.commit() + await db_session.flush() + return user + @pytest.fixture() async def host_user_calendar(db_session: AsyncSession, host_user: account_models.User): calendar = calendar_models.Calendar( @@ -131,8 +131,8 @@ async def host_user_calendar(db_session: AsyncSession, host_user: account_models topics=["푸딩캠프", "푸딩캠프2"], google_calendar_id="1234567890", ) - db_session.add(calendar) - await db_session.commit() - await db_session.refresh(host_user) - await db_session.flush() + db_session.add(calendar) + await db_session.commit() + await db_session.refresh(host_user) + await db_session.flush() return calendar diff --git a/tests/libs/datetime/test_calendar.py b/tests/libs/datetime/test_calendar.py index 7483653..74ed7ef 100644 --- a/tests/libs/datetime/test_calendar.py +++ b/tests/libs/datetime/test_calendar.py @@ -1,57 +1,57 @@ -from appserver.libs.datetime.calendar import get_start_weekday_of_month, get_last_day_of_month, get_range_days_of_month -import pytest - -def test_get_start_weekday_of_month(): - assert get_start_weekday_of_month(2024, 12) == 6 - assert get_start_weekday_of_month(2025, 2) == 5 - -def test_get_last_day_of_month(): - assert get_last_day_of_month(2024, 2) == 29 - assert get_last_day_of_month(2025, 2) == 28 - assert get_last_day_of_month(2024, 4) == 30 - assert get_last_day_of_month(2024, 12) == 31 - -# @pytest.mark.parametrize: 데이터 기반 테스트 장식자 -# 테스트 함수 하나를 복사-붙여넣기를 해서 여러 개 만들 필요 없이, -# 데이터만 리스트로 관리하기 때문에 코드가 매우 깔끔 -@pytest.mark.parametrize("year, month, expected", [ - (2024, 12, 6), # 케이스 1: 2024년 12월은 일요일(6) 시작 - (2025, 2, 5), # 케이스 2: 2025년 2월은 토요일(5) 시작 -]) -def test_get_start_weekday_of_month(year, month, expected): - """ - 다양한 연도와 월에 대해 시작 요일 계산 로직을 검증합니다. - - Args: - year (int): 테스트할 연도 - month (int): 테스트할 월 - expected (int): 기대되는 시작 요일 결과값 (0~6) - """ - # [주석 공부 포인트] - # parametrize에 적힌 데이터들이 순서대로 year, month, expected 변수에 들어옵니다. - # 즉, 이 함수는 내부적으로 총 2번(리스트의 개수만큼) 실행됩니다. - assert get_start_weekday_of_month(year, month) == expected - -@pytest.mark.parametrize("year, month, expected", [ - (2024, 2, 29), - (2025, 2, 28), - (2024, 4, 30), - (2024, 12, 31), -]) -def test_get_last_day_of_month(year, month, expected): - assert get_last_day_of_month(year, month) == expected - -@pytest.mark.parametrize("year, month, expected_padding_count, expected_total_count", [ - (2024, 3, 5, 36), - (2024, 2, 4, 33), - (2025, 2, 6, 34), - (2024, 4, 1, 31), - (2024, 12, 0, 31), -]) -def test_get_range_days_of_month(year, month, expected_padding_count, expected_total_count): - days = get_range_days_of_month(year, month) - padding_count = days[:expected_padding_count] - - assert sum(padding_count) == 0 - assert days[expected_padding_count] == 1 +from appserver.libs.datetime.calendar import get_start_weekday_of_month, get_last_day_of_month, get_range_days_of_month +import pytest + +def test_get_start_weekday_of_month(): + assert get_start_weekday_of_month(2024, 12) == 6 + assert get_start_weekday_of_month(2025, 2) == 5 + +def test_get_last_day_of_month(): + assert get_last_day_of_month(2024, 2) == 29 + assert get_last_day_of_month(2025, 2) == 28 + assert get_last_day_of_month(2024, 4) == 30 + assert get_last_day_of_month(2024, 12) == 31 + +# @pytest.mark.parametrize: 데이터 기반 테스트 장식자 +# 테스트 함수 하나를 복사-붙여넣기를 해서 여러 개 만들 필요 없이, +# 데이터만 리스트로 관리하기 때문에 코드가 매우 깔끔 +@pytest.mark.parametrize("year, month, expected", [ + (2024, 12, 6), # 케이스 1: 2024년 12월은 일요일(6) 시작 + (2025, 2, 5), # 케이스 2: 2025년 2월은 토요일(5) 시작 +]) +def test_get_start_weekday_of_month(year, month, expected): + """ + 다양한 연도와 월에 대해 시작 요일 계산 로직을 검증합니다. + + Args: + year (int): 테스트할 연도 + month (int): 테스트할 월 + expected (int): 기대되는 시작 요일 결과값 (0~6) + """ + # [주석 공부 포인트] + # parametrize에 적힌 데이터들이 순서대로 year, month, expected 변수에 들어옵니다. + # 즉, 이 함수는 내부적으로 총 2번(리스트의 개수만큼) 실행됩니다. + assert get_start_weekday_of_month(year, month) == expected + +@pytest.mark.parametrize("year, month, expected", [ + (2024, 2, 29), + (2025, 2, 28), + (2024, 4, 30), + (2024, 12, 31), +]) +def test_get_last_day_of_month(year, month, expected): + assert get_last_day_of_month(year, month) == expected + +@pytest.mark.parametrize("year, month, expected_padding_count, expected_total_count", [ + (2024, 3, 5, 36), + (2024, 2, 4, 33), + (2025, 2, 6, 34), + (2024, 4, 1, 31), + (2024, 12, 0, 31), +]) +def test_get_range_days_of_month(year, month, expected_padding_count, expected_total_count): + days = get_range_days_of_month(year, month) + padding_count = days[:expected_padding_count] + + assert sum(padding_count) == 0 + assert days[expected_padding_count] == 1 assert len(days) == expected_total_count \ No newline at end of file diff --git a/tests/test_hello.py b/tests/test_hello.py index 7b077ca..b31c707 100644 --- a/tests/test_hello.py +++ b/tests/test_hello.py @@ -1,13 +1,13 @@ -# 테스트-hello -def test_hello(): - assert True - -def test_hello(): - assert False - -# 더하기 함수 테스트하기 -def add(a, b): - return a + b - -def test_add(): +# 테스트-hello +def test_hello(): + assert True + +def test_hello(): + assert False + +# 더하기 함수 테스트하기 +def add(a, b): + return a + b + +def test_add(): assert add(1, 2) == 3 \ No newline at end of file