Repository: DE-Project1/Where2Eat-BE
Pull request #1: Feat: FastAPI 프로젝트 세팅, 서비스 API 개발
Author: login: seohyun-lee
Created: 2025-05-03T19:02:27Z | Merged: 2025-05-03T19:52:17Z

FastAPI 프로젝트 세팅, 서비스 API를 개발했습니다.


Diff:
diff --git a/.gitignore b/.gitignore
index 0a19790..c64c7a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -172,3 +172,7 @@ cython_debug/
 
 # PyPI configuration file
 .pypirc
+
+**/.idea
+**/.DS_Store
+**/.vscode
\ No newline at end of file
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..2c07333
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.11
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..dae5677
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,14 @@
+[project]
+name = "where2eat-be"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.115.12",
+    "pydantic-settings>=2.9.1",
+    "pymongo>=4.12.1",
+    "pytest>=8.3.5",
+    "python-dotenv>=1.1.0",
+    "uvicorn>=0.34.2",
+]
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/api/__init__.py b/src/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/api/routers.py b/src/api/routers.py
new file mode 100644
index 0000000..7a32140
--- /dev/null
+++ b/src/api/routers.py
@@ -0,0 +1,60 @@
+from fastapi import APIRouter, HTTPException, Depends, Query
+from typing import List
+from src.application.dtos import ClusterDTO, PlaceDTO, SituationDefinitionDTO
+from src.application.services import (
+    get_clusters_by_region,
+    get_places_by_region_and_cluster,
+    get_situation_definitions_by_place_id,
+)
+
+router = APIRouter(prefix="/regions", tags=["regions"])
+
+@router.get(
+    "/clusters",
+    response_model=List[ClusterDTO],
+    summary="행정구역별 상황 클러스터 리스트 조회",
+    description="district(시군구명)와 neighborhood(읍면동명)을 받아, 해당 지역에 속한 상황 클러스터 목록을 반환합니다."
+)
+def select_region_for_clusters(
+    district: str = Query(..., description="시군구명"),
+    neighborhood: str = Query(..., description="읍면동명"),
+    get_clusters=Depends(get_clusters_by_region),
+):
+    clusters = get_clusters(district, neighborhood)
+    if not clusters:
+        raise HTTPException(status_code=404, detail="No clusters found")
+    return clusters
+
+
+@router.get(
+    "/places",
+    response_model=List[PlaceDTO],
+    summary="클러스터별 장소 리스트 조회",
+    description="district, neighborhood, cluster_id를 받아, 해당 클러스터에 속한 장소 목록을 반환합니다."
+)
+def select_region_and_cluster_for_places(
+    district: str = Query(..., description="시군구명"),
+    neighborhood: str = Query(..., description="읍면동명"),
+    cluster_id: int = Query(..., description="상황 클러스터 ID"),
+    get_places=Depends(get_places_by_region_and_cluster),
+):
+    places = get_places(district, neighborhood, cluster_id)
+    if not places:
+        raise HTTPException(status_code=404, detail="No places found")
+    return places
+
+
+@router.get(
+    "/places/{place_id}",
+    response_model=List[SituationDefinitionDTO],
+    summary="장소별 리뷰 상황 설명 조회 (페이지네이션)",
+    description="place_id와 page를 받아, 해당 장소의 리뷰 상황 설명을 최대 10개씩 페이징해서 반환합니다."
+)
+def get_situation_definitions_of_place(
+    place_id: int,
+    page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
+    get_situation_definitions=Depends(get_situation_definitions_by_place_id),
+):
+    results = get_situation_definitions(place_id, page)
+    # 페이지에 결과가 없더라도 빈 리스트 반환
+    return results
\ No newline at end of file
diff --git a/src/application/__init__.py b/src/application/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/application/dtos.py b/src/application/dtos.py
new file mode 100644
index 0000000..e3c8921
--- /dev/null
+++ b/src/application/dtos.py
@@ -0,0 +1,22 @@
+# src/application/dtos.py
+
+from pydantic import BaseModel, Field
+from typing import Optional, List
+
+class ClusterDTO(BaseModel):
+    cluster_id: int = Field(..., description="클러스터 ID")
+    cluster_name: str = Field(..., description="클러스터 이름")
+    places_count: int = Field(..., description="클러스터별 장소 수")
+
+
+class PlaceDTO(BaseModel):
+    place_id: int = Field(..., description="장소 고유 ID")
+    adm_dong_code: float = Field(..., description="행정동코드")
+    name: str = Field(..., description="장소명")
+    category: Optional[str] = Field(None, description="카테고리")
+    address: Optional[str] = Field(None, description="주소")
+    opening_hours: Optional[str] = Field(None, description="영업 시간")
+    naver_rating: float | None = None
+
+class SituationDefinitionDTO(BaseModel):
+    situation_definition: Optional[str] = Field(None, description="리뷰 상황 설명")
diff --git a/src/application/services.py b/src/application/services.py
new file mode 100644
index 0000000..09d353e
--- /dev/null
+++ b/src/application/services.py
@@ -0,0 +1,122 @@
+from typing import List, Dict, Any
+from src.infrastructure.db import db
+import math
+
+def clean_nan(obj):
+    if isinstance(obj, dict):
+        return {k: clean_nan(v) for k, v in obj.items()}
+    elif isinstance(obj, list):
+        return [clean_nan(v) for v in obj]
+    elif isinstance(obj, float) and math.isnan(obj):
+        return None
+    else:
+        return obj
+
+# 구/동을 받아 해당 지역의 클러스터(cluster_id, cluster_name, places_count) 조회
+def get_clusters_by_region():
+    def uc(district: str, neighborhood: str) -> List[Dict[str, Any]]:
+        # 1) 행정동 코드 조회
+        region = db["region_map"].find_one(
+            {"district": district, "neighborhood": neighborhood},
+            {"adm_dong_code": 1, "_id": 0},
+        )
+        if not region:
+            return []
+
+        adm_dong = region["adm_dong_code"]
+
+        # 2) 해당 동의 모든 place_id 리스트 (인덱스가 있다면 매우 빠름)
+        place_ids = [
+            doc["place_id"]
+            for doc in db["place_info"].find(
+                {"adm_dong_code": adm_dong},
+                {"place_id": 1, "_id": 0},
+            )
+        ]
+        if not place_ids:
+            return []
+
+        # 3) reviews 컬렉션만으로 그룹화 → 고유 place_id 세트로 묶은 뒤 count
+        pipeline = [
+            {"$match": {"place_id": {"$in": place_ids}}},
+            {
+                "$group": {
+                    "_id": {
+                        "cluster_id": "$situation_cluster",
+                        "cluster_name": "$cluster_name",
+                    },
+                    "place_ids": {"$addToSet": "$place_id"},
+                }
+            },
+            {
+                "$project": {
+                    "cluster_id": "$_id.cluster_id",
+                    "cluster_name": "$_id.cluster_name",
+                    "places_count": {"$size": "$place_ids"},
+                    "_id": 0,
+                }
+            },
+        ]
+        result = list(db["reviews"].aggregate(pipeline))
+        return clean_nan(result)
+
+    return uc
+
+# 구/동과 클러스터 ID를 받아 해당 클러스터의 장소 리스트를 빠르게 조회
+def get_places_by_region_and_cluster():
+    def uc(district: str, neighborhood: str, cluster_id: int) -> List[Dict[str, Any]]:
+        # 1) 행정동 코드 조회
+        region = db["region_map"].find_one(
+            {"district": district, "neighborhood": neighborhood},
+            {"adm_dong_code": 1, "_id": 0},
+        )
+        if not region:
+            return []
+
+        adm_dong = region["adm_dong_code"]
+
+        # 2) 해당 동의 place_id 목록 조회 (adm_dong_code 인덱스 활용)
+        place_ids = db["place_info"].distinct(
+            "place_id",
+            {"adm_dong_code": adm_dong}
+        )
+        if not place_ids:
+            return []
+
+        # 3) reviews에서 해당 cluster_id와 place_id 필터 후 고유 place_id만 추출 (복합 인덱스 활용)
+        matched_place_ids = db["reviews"].distinct(
+            "place_id",
+            {
+                "place_id": {"$in": place_ids},
+                "situation_cluster": cluster_id
+            }
+        )
+        if not matched_place_ids:
+            return []
+
+        # 4) 최종 place_info 문서 조회하여 반환 (_id 제외)
+        places = list(db["place_info"].find(
+            {"place_id": {"$in": matched_place_ids}},
+            {"_id": 0}
+        ))
+        return clean_nan(places)
+
+    return uc
+
+# 장소 ID로 리뷰의 situation_definition을 10개씩 페이징 조회
+def get_situation_definitions_by_place_id():
+    def uc(place_id: int, page: int = 1) -> List[Dict[str, Any]]:
+        # 페이지 계산
+        skip = (page - 1) * 10
+
+        # situation_definition만 추출하여 skip/limit 적용
+        pipeline = [
+            {"$match": {"place_id": place_id}},
+            {"$project": {"situation_definition": 1, "_id": 0}},
+            {"$skip": skip},
+            {"$limit": 10},
+        ]
+        result = list(db["reviews"].aggregate(pipeline))
+        return clean_nan(result)
+
+    return uc
diff --git a/src/core/__init__.py b/src/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/config.py b/src/core/config.py
new file mode 100644
index 0000000..68e525b
--- /dev/null
+++ b/src/core/config.py
@@ -0,0 +1,11 @@
+from pydantic_settings import BaseSettings
+
+class Settings(BaseSettings):
+    mongo_uri: str
+    mongo_db: str
+
+    class Config:
+        env_file = ".env"
+        env_file_encoding = "utf-8"
+
+settings = Settings()
\ No newline at end of file
diff --git a/src/domain/__init__.py b/src/domain/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/domain/entities.py b/src/domain/entities.py
new file mode 100644
index 0000000..443e5cc
--- /dev/null
+++ b/src/domain/entities.py
@@ -0,0 +1,26 @@
+from pydantic import BaseModel, Field
+from typing import List, Optional
+
+
+class Place(BaseModel):
+    place_id: int = Field(..., description="장소 고유 ID")
+    adm_dong_code: int = Field(..., description="행정동 코드")
+    name: str = Field(..., description="장소명")
+    category: str = Field(..., description="카테고리")
+    address: str = Field(..., description="주소")
+    opening_hours: Optional[str] = None
+    naver_rating: Optional[float] = None
+
+
+class Region(BaseModel):
+    adm_dong_code: int = Field(..., description="행정동 코드")
+    district: str = Field(..., description="시군구명")
+    neighborhood: str = Field(..., description="읍면동명")
+
+class Review(BaseModel):
+    place_id: int = Field(..., description="장소 고유 ID")
+    visit_count: Optional[float] = None
+    content_nouns: Optional[List[str]] = None
+    situation_definition: Optional[str] = None
+    situation_cluster: Optional[int] = None
+    cluster_name: Optional[str] = None
diff --git a/src/domain/repositories.py b/src/domain/repositories.py
new file mode 100644
index 0000000..1ee54e6
--- /dev/null
+++ b/src/domain/repositories.py
@@ -0,0 +1,14 @@
+from typing import List, Optional
+from src.domain.entities import Place, Region, Review
+
+class PlaceRepository:
+    def get(self, place_id: int) -> Optional[Place]:
+        raise NotImplementedError
+
+class RegionRepository:
+    def get(self, adm_dong_code: int) -> Optional[Region]:
+        raise NotImplementedError
+
+class ReviewRepository:
+    def list(self, place_id: int, limit: int) -> List[Review]:
+        raise NotImplementedError
\ No newline at end of file
diff --git a/src/infrastructure/__init__.py b/src/infrastructure/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/infrastructure/db.py b/src/infrastructure/db.py
new file mode 100644
index 0000000..efc8d0b
--- /dev/null
+++ b/src/infrastructure/db.py
@@ -0,0 +1,5 @@
+from pymongo import MongoClient
+from src.core.config import settings
+
+client = MongoClient(settings.mongo_uri)
+db = client[settings.mongo_db]
diff --git a/src/infrastructure/repositories_impl.py b/src/infrastructure/repositories_impl.py
new file mode 100644
index 0000000..a9384f2
--- /dev/null
+++ b/src/infrastructure/repositories_impl.py
@@ -0,0 +1,48 @@
+import logging
+from typing import List, Optional, Dict, Any
+from pymongo.errors import PyMongoError
+
+from src.domain.entities import Place, Region, Review
+from src.domain.repositories import PlaceRepository, RegionRepository, ReviewRepository
+from src.infrastructure.db import db
+
+logger = logging.getLogger(__name__)
+
+class MongoPlaceRepository(PlaceRepository):
+    def get(self, place_id: int) -> Optional[Place]:
+        try:
+            doc = db["place_info"].find_one(
+                {"place_id": place_id},
+                {"_id": 0}
+            )
+            return Place(**doc) if doc else None
+
+        except PyMongoError as e:
+            logger.error("MongoPlaceRepository.get failed for place_id=%s: %s", place_id, e, exc_info=True)
+            return None
+
+class MongoRegionRepository(RegionRepository):
+    def get(self, adm_dong_code: int) -> Optional[Region]:
+        try:
+            doc = db["region_map"].find_one(
+                {"adm_dong_code": adm_dong_code},
+                {"_id": 0}
+            )
+            return Region(**doc) if doc else None
+
+        except PyMongoError as e:
+            logger.error("MongoRegionRepository.get failed for adm_dong_code=%s: %s", adm_dong_code, e, exc_info=True)
+            return None
+
+class MongoReviewRepository(ReviewRepository):
+    def list(self, place_id: int, limit: int) -> List[Review]:
+        try:
+            cursor = db["reviews"].find(
+                {"place_id": place_id},
+                {"_id": 0}
+            ).limit(limit)
+            return [Review(**doc) for doc in cursor]
+
+        except PyMongoError as e:
+            logger.error("MongoReviewRepository.list failed for place_id=%s: %s", place_id, e, exc_info=True)
+            return []
\ No newline at end of file
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..fc2b9aa
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,30 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from src.api.routers import router
+from fastapi.responses import FileResponse
+
+app = FastAPI(title="Where2Eat BE")
+
+origins = [
+    "http://localhost:3000", "http://localhost:3001",
+    "http://localhost:5173", "http://localhost:5174",
+    "http://localhost:8000", "https://where2eat.vercel.app"
+]
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=origins,
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"]
+)
+
+app.include_router(router)
+
+@app.get("/", include_in_schema=False)
+def root():
+    return {"Where2Eat Server is running."}
+
+@app.get("/favicon.ico", include_in_schema=False)
+async def favicon():
+    return FileResponse("static/favicon.ico")
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..73ce10c
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,15 @@
+import os, sys
+import pytest
+
+# 프로젝트 루트를 모듈 경로에 추가
+sys.path.insert(0, os.getcwd())
+
+# 테스트용 기본 환경 변수 설정
+os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017")
+os.environ.setdefault("MONGO_DB", "testdb")
+
+@pytest.fixture(autouse=True)
+def override_env_vars(monkeypatch):
+    monkeypatch.setenv("MONGO_URI", os.environ["MONGO_URI"])
+    monkeypatch.setenv("MONGO_DB", os.environ["MONGO_DB"])
+    yield
diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/functional/test_regions.py b/tests/functional/test_regions.py
new file mode 100644
index 0000000..efdbd78
--- /dev/null
+++ b/tests/functional/test_regions.py
@@ -0,0 +1,100 @@
+import os
+import sys
+from fastapi.testclient import TestClient
+from src.main import app
+
+client = TestClient(app)
+
+# 현재 tests 폴더의 한 단계 위(프로젝트 루트)를 PYTHONPATH에 추가
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+import pytest
+from fastapi.testclient import TestClient
+from src.main import app
+from src.application.services import (
+    get_clusters_by_region,
+    get_places_by_region_and_cluster,
+    get_situation_definitions_by_place_id,
+)
+
+client = TestClient(app)
+
+@pytest.fixture(autouse=True)
+def override_dependencies():
+    # clusters용 더미 의존성: 항상 1개짜리 리스트 반환
+    def fake_get_clusters():
+        return lambda district, neighborhood: [
+            {"cluster_id": 1, "cluster_name": "테스트 클러스터", "places_count": 3}
+        ]
+
+    # places용 더미 의존성: 항상 1개짜리 리스트 반환
+    def fake_get_places():
+        return lambda district, neighborhood, cluster_id: [
+            {
+                "place_id": 10,
+                "adm_dong_code": 1111051500,
+                "name": "테스트 식당",
+                "category": "한식",
+                "address": "테스트 주소",
+                "opening_hours": "10:00-22:00",
+                "naver_rating": 4.2,
+            }
+        ]
+
+    # situation_definitions용 더미 의존성: 항상 1개짜리 리스트 반환
+    def fake_get_defs():
+        return lambda place_id, page=1: [
+            {"situation_definition": "테스트 상황 설명"}
+        ]
+
+    # 의존성 오버라이드
+    app.dependency_overrides[get_clusters_by_region] = fake_get_clusters
+    app.dependency_overrides[get_places_by_region_and_cluster] = fake_get_places
+    app.dependency_overrides[get_situation_definitions_by_place_id] = fake_get_defs
+
+    yield
+
+    # 테스트 후 정리
+    app.dependency_overrides.clear()
+
+
+def test_get_clusters_success():
+    r = client.get("/regions/clusters?district=종로구&neighborhood=청운효자동")
+    assert r.status_code == 200
+    assert r.json() == [
+        {"cluster_id": 1, "cluster_name": "테스트 클러스터", "places_count": 3}
+    ]
+
+
+def test_get_clusters_not_found():
+    # 빈 리스트 반환하도록 의존성만 교체
+    app.dependency_overrides[get_clusters_by_region] = lambda: (lambda d, n: [])
+    r = client.get("/regions/clusters?district=없음구&neighborhood=없음동")
+    assert r.status_code == 404
+
+
+def test_get_places_success():
+    r = client.get("/regions/places?district=종로구&neighborhood=청운효자동&cluster_id=1")
+    assert r.status_code == 200
+    data = r.json()
+    assert isinstance(data, list) and data[0]["place_id"] == 10
+
+
+def test_get_places_not_found():
+    app.dependency_overrides[get_places_by_region_and_cluster] = lambda: (lambda d, n, s: [])
+    r = client.get("/regions/places?district=종로구&neighborhood=청운효자동&cluster_id=999")
+    assert r.status_code == 404
+
+
+def test_get_situation_definitions_success():
+    r = client.get("/regions/places/10?page=1")
+    assert r.status_code == 200
+    assert r.json() == [{"situation_definition": "테스트 상황 설명"}]
+
+
+def test_get_situation_definitions_empty():
+    # 정의가 없을 땐 빈 리스트(200) 반환
+    app.dependency_overrides[get_situation_definitions_by_place_id] = lambda: (lambda p, pg=1: [])
+    r = client.get("/regions/places/10?page=2")
+    assert r.status_code == 200
+    assert r.json() == []
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..a172811
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,358 @@
+version = 1
+revision = 1
+requires-python = ">=3.11"
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "sniffio" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
+]
+
+[[package]]
+name = "click"
+version = "8.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.115.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "starlette" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.11.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.33.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 },
+    { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 },
+    { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 },
+    { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 },
+    { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 },
+    { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 },
+    { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 },
+    { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 },
+    { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 },
+    { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 },
+    { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 },
+    { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 },
+    { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 },
+    { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 },
+    { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 },
+    { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 },
+    { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 },
+    { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 },
+    { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 },
+    { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 },
+    { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 },
+    { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 },
+    { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 },
+    { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 },
+    { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 },
+    { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 },
+    { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 },
+    { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 },
+    { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 },
+    { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 },
+    { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 },
+    { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 },
+    { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 },
+    { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 },
+    { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 },
+    { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 },
+    { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 },
+    { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 },
+    { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 },
+    { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 },
+    { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 },
+    { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 },
+    { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 },
+    { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 },
+    { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
+    { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 },
+    { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 },
+    { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 },
+    { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 },
+    { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 },
+    { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 },
+    { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 },
+    { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 },
+    { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "python-dotenv" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 },
+]
+
+[[package]]
+name = "pymongo"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "dnspython" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/85/27/3634b2e8d88ad210ee6edac69259c698aefed4a79f0f7356cd625d5c423c/pymongo-4.12.1.tar.gz", hash = "sha256:8921bac7f98cccb593d76c4d8eaa1447e7d537ba9a2a202973e92372a05bd1eb", size = 2165515 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/28/af/2cf9a4871615481841b791eb8f3cdf3e5b909ff66c258ae23cd91da90006/pymongo-4.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72b45f7e72b2db4cd7abd40c38c57ed4105d7be0d4dce85a6b77a730e8a613f7", size = 855938 },
+    { url = "https://files.pythonhosted.org/packages/29/78/3196c9d9b08dbf5c600b485f610821cd755f978bfb9c51f51651139d0231/pymongo-4.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0f3104bd97642f508f70a83af256b9d88e9a7319e8048c27f1c8ca6572ad7b7f", size = 856228 },
+    { url = "https://files.pythonhosted.org/packages/19/38/63a7cf5e76c99ee31fa9cadd5570525338e5a2fe5e8085a5e3af85cf4fa7/pymongo-4.12.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:730a19d96ef902ee8d8f9e84738142d355096becb677ec82489dc9ad8e54d8e9", size = 1425307 },
+    { url = "https://files.pythonhosted.org/packages/16/f9/2d8a0d78da2891ea744fac4a9435640e3fa47d23eb68a1b346819d930d78/pymongo-4.12.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dd2b771387e3ac297399b7b4d9a4bfffbaabba6f17c79996e8462cde3e7c30", size = 1476260 },
+    { url = "https://files.pythonhosted.org/packages/13/1e/b5b6a994862e732f83638b8bc94e68bca1b135a2bcf1b21c5661eb49e5c6/pymongo-4.12.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5e5968da22f5534fc678dad58d3e9f7305bf53abc94968c800335b1f511ab8b", size = 1450686 },
+    { url = "https://files.pythonhosted.org/packages/9e/2a/56e0c160c19e2c8b7c7ae541db8acc17ef8091cec8d419be7c31ec20d2ee/pymongo-4.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc5fad32274a1de9dfe13d06da169cf2a405a98f049595aafda13af02921853e", size = 1429812 },
+    { url = "https://files.pythonhosted.org/packages/d6/34/bac2dfc038a7fb5e0520ab6803eebc7d05dc7dfe0629c442baceb4f43d95/pymongo-4.12.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:808168f5f4398c0057d15f21b1453de323157447915179c7afedf4334d2a1815", size = 1398454 },
+    { url = "https://files.pythonhosted.org/packages/fa/3d/2c9ef7bed070025438e08201323a08360a98381b01b9bae49781b9d27815/pymongo-4.12.1-cp311-cp311-win32.whl", hash = "sha256:ee69dba3e023e0fa1b547b4f7a41182618f2e612df09ff954bba32de0111a596", size = 833157 },
+    { url = "https://files.pythonhosted.org/packages/16/5d/3e1f7177387ec84e1a1538d29eef05791ceb19cbb80b09a2ae9b74ad518c/pymongo-4.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:40e2812e5b546f7ceef4abf82c31d4790d9878f2a0d43a67a2645de3eb06bdca", size = 847091 },
+    { url = "https://files.pythonhosted.org/packages/59/dd/b684de28bfaf7e296538601c514d4613f98b77cfa1de323c7b160f4e04d0/pymongo-4.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b771aa2f0854ddf7861e8ce2365f29df9159393543d047e43d8475bc4b8813", size = 910797 },
+    { url = "https://files.pythonhosted.org/packages/e8/80/4fadd5400a4fbe57e7ea0349f132461d5dfc46c124937600f5044290d817/pymongo-4.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:34fd8681b6fa6e1025dd1000004f6b81cbf1961f145b8c58bd15e3957976068d", size = 910489 },
+    { url = "https://files.pythonhosted.org/packages/4e/83/303be22944312cc28e3a357556d21971c388189bf90aebc79e752afa2452/pymongo-4.12.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:981e19b8f1040247dee5f7879e45f640f7e21a4d87eabb19283ce5a2927dd2e7", size = 1689142 },
+    { url = "https://files.pythonhosted.org/packages/a4/67/f4e8506caf001ab9464df2562e3e022b7324e7c10a979ce1b55b006f2445/pymongo-4.12.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9a487dc1fe92736987a156325d3d9c66cbde6eac658b2875f5f222b6d82edca", size = 1753373 },
+    { url = "https://files.pythonhosted.org/packages/2e/7c/22d65c2a4e3e941b345b8cc164b3b53f2c1d0db581d4991817b6375ef507/pymongo-4.12.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1525051c13984365c4a9b88ee2d63009fae277921bc89a0d323b52c51f91cbac", size = 1722399 },
+    { url = "https://files.pythonhosted.org/packages/07/0d/32fd1ebafd0090510fb4820d175fe35d646e5b28c71ad9c36cb3ce554567/pymongo-4.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ad689e0e4f364809084f9e5888b2dcd6f0431b682a1c68f3fdf241e20e14475", size = 1692374 },
+    { url = "https://files.pythonhosted.org/packages/e3/9c/d7a30ce6b983c3955c225e3038dafb4f299281775323f58b378f2a7e6e59/pymongo-4.12.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f9b18abca210c2917041ab2a380c12f6ddd2810844f1d64afb39caf8a15425e", size = 1651490 },
+    { url = "https://files.pythonhosted.org/packages/29/b3/7902d73df1d088ec0c60c19ef4bd7894c6e6e4dfbfd7ab4ae4fbedc9427c/pymongo-4.12.1-cp312-cp312-win32.whl", hash = "sha256:d9d90fec041c6d695a639c26ca83577aa74383f5e3744fd7931537b208d5a1b5", size = 879521 },
+    { url = "https://files.pythonhosted.org/packages/8c/68/a17ff6472e6be12bae75f5d11db4e3dccc55e02dcd4e66cd87871790a20e/pymongo-4.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:d004b13e4f03d73a3ad38505ba84b61a2c8ba0a304f02fe1b27bfc986c244192", size = 897765 },
+    { url = "https://files.pythonhosted.org/packages/0c/4d/e6654f3ec6819980cbad77795ccf2275cd65d6df41375a22cdbbccef8416/pymongo-4.12.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:90de2b060d69c22658ada162a5380a0f88cb8c0149023241b9e379732bd36152", size = 965051 },
+    { url = "https://files.pythonhosted.org/packages/54/95/627a047c32789544a938abfd9311c914e622cb036ad16866e7e1b9b80239/pymongo-4.12.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edf4e05331ac875d3b27b4654b74d81e44607af4aa7d6bcd4a31801ca164e6fd", size = 964732 },
+    { url = "https://files.pythonhosted.org/packages/8f/6d/7a604e3ab5399f8fe1ca88abdbf7e54ceb6cf03e64f68b2ed192d9a5eaf5/pymongo-4.12.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa7a817c9afb7b8775d98c469ddb3fe9c17daf53225394c1a74893cf45d3ade9", size = 1953037 },
+    { url = "https://files.pythonhosted.org/packages/d5/d5/269388e7b0d02d35f55440baf1e0120320b6db1b555eaed7117d04b35402/pymongo-4.12.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9d142ca531694e9324b3c9ba86c0e905c5f857599c4018a386c4dc02ca490fa", size = 2030467 },
+    { url = "https://files.pythonhosted.org/packages/4b/d0/04a6b48d6ca3fc2ff156185a3580799a748cf713239d6181e91234a663d3/pymongo-4.12.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5d4c0461f5cd84d9fe87d5a84b1bc16371c4dd64d56dcfe5e69b15c0545a5ac", size = 1994139 },
+    { url = "https://files.pythonhosted.org/packages/ad/65/0567052d52c0ac8aaa4baa700b39cdd1cf2481d2e59bd9817a3daf169ca0/pymongo-4.12.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43afd2f39182731ac9fb81bbc9439d539e4bd2eda72cdee829d2fa906a1c4d37", size = 1954947 },
+    { url = "https://files.pythonhosted.org/packages/c5/5b/db25747b288218dbdd97e9aeff6a3bfa3f872efb4ed06fa8bec67b2a121e/pymongo-4.12.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:827ac668c003da7b175b8e5f521850e2c182b4638a3dec96d97f0866d5508a1e", size = 1904374 },
+    { url = "https://files.pythonhosted.org/packages/fc/1e/6d0eb040c02ae655fafd63bd737e96d7e832eecfd0bd37074d0066f94a78/pymongo-4.12.1-cp313-cp313-win32.whl", hash = "sha256:7c2269b37f034124a245eaeb34ce031cee64610437bd597d4a883304babda3cd", size = 925869 },
+    { url = "https://files.pythonhosted.org/packages/59/b9/459da646d9750529f04e7e686f0cd8dd40174138826574885da334c01b16/pymongo-4.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:3b28ecd1305b89089be14f137ffbdf98a3b9f5c8dbbb2be4dec084f2813fbd5f", size = 948411 },
+    { url = "https://files.pythonhosted.org/packages/c9/c3/75be116159f210811656ec615b2248f63f1bc9dd1ce641e18db2552160f0/pymongo-4.12.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f27b22a8215caff68bdf46b5b61ccd843a68334f2aa4658e8d5ecb5d3fbebb3b", size = 1021562 },
+    { url = "https://files.pythonhosted.org/packages/cd/d1/2e8e368cad1c126a68365a6f53feaade58f9a16bd5f7a69f218af119b0e9/pymongo-4.12.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e9d23a3c290cf7409515466a7f11069b70e38ea2b786bbd7437bdc766c9e176", size = 1021553 },
+    { url = "https://files.pythonhosted.org/packages/17/6e/a6460bc1e3d3f5f46cc151417427b2687a6f87972fd68a33961a37c114df/pymongo-4.12.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efeb430f7ca8649a6544a50caefead343d1fd096d04b6b6a002c6ce81148a85c", size = 2281736 },
+    { url = "https://files.pythonhosted.org/packages/1a/e2/9e1d6f1a492bb02116074baa832716805a0552d757c176e7c5f40867ca80/pymongo-4.12.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a34e4a08bbcff56fdee86846afbc9ce751de95706ca189463e01bf5de3dd9927", size = 2368964 },
+    { url = "https://files.pythonhosted.org/packages/fa/df/88143016eca77e79e38cf072476c70dd360962934430447dabc9c6bef6df/pymongo-4.12.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b063344e0282537f05dbb11147591cbf58fc09211e24fc374749e343f880910a", size = 2327834 },
+    { url = "https://files.pythonhosted.org/packages/3c/0d/df2998959b52cd5682b11e6eee1b0e0c104c07abd99c9cde5a871bb299fd/pymongo-4.12.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3f7941e01b3e5d4bfb3b4711425e809df8c471b92d1da8d6fab92c7e334a4cb", size = 2279126 },
+    { url = "https://files.pythonhosted.org/packages/fb/3e/102636f5aaf97ccfa2a156c253a89f234856a0cd252fa602d4bf077ba3c0/pymongo-4.12.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41235014031739f32be37ff13992f51091dae9a5189d3bcc22a5bf81fd90dae", size = 2218136 },
+    { url = "https://files.pythonhosted.org/packages/44/c9/1b534c9d8d91d9d98310f2d955c5331fb522bd2a0105bd1fc31771d53758/pymongo-4.12.1-cp313-cp313t-win32.whl", hash = "sha256:9a1f07fe83a8a34651257179bd38d0f87bd9d90577fcca23364145c5e8ba1bc0", size = 974747 },
+    { url = "https://files.pythonhosted.org/packages/08/e2/7d3a30ac905c99ea93729e03d2bb3d16fec26a789e98407d61cb368ab4bb/pymongo-4.12.1-cp313-cp313t-win_amd64.whl", hash = "sha256:46d86cf91ee9609d0713242a1d99fa9e9c60b4315e1a067b9a9e769bedae629d", size = 1003332 },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
+]
+
+[[package]]
+name = "starlette"
+version = "0.46.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 },
+]
+
+[[package]]
+name = "where2eat-be"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+    { name = "fastapi" },
+    { name = "pydantic-settings" },
+    { name = "pymongo" },
+    { name = "pytest" },
+    { name = "python-dotenv" },
+    { name = "uvicorn" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = ">=0.115.12" },
+    { name = "pydantic-settings", specifier = ">=2.9.1" },
+    { name = "pymongo", specifier = ">=4.12.1" },
+    { name = "pytest", specifier = ">=8.3.5" },
+    { name = "python-dotenv", specifier = ">=1.1.0" },
+    { name = "uvicorn", specifier = ">=0.34.2" },
+]


Reviews:
user: login: gemini-code-assist[bot]
body: ## Code Review
The pull request introduces the initial setup for a FastAPI project and develops service APIs. The code appears well-structured and follows common FastAPI patterns. However, there are some areas that could be improved for better maintainability, efficiency, and clarity.

### Summary of Findings
* **Missing Error Handling in Repository Implementations**: The `MongoPlaceRepository`, `MongoRegionRepository`, and `MongoReviewRepository` include error logging but return `None` or an empty list in case of a `PyMongoError`. It would be better to raise the exception to allow the service layer to handle it appropriately.
* **Potential Inefficiency in `get_clusters_by_region`**: The `get_clusters_by_region` function retrieves all `place_id`s for a given region before grouping by cluster. This could be inefficient for regions with a large number of places. Consider optimizing the query to directly group by cluster within the region.
* **Inconsistent Use of `Optional` in DTOs**: Some fields in the DTOs, such as `category` and `address` in `PlaceDTO`, are defined as `Optional[str]`, while others, like `naver_rating`, use `float | None`. Maintaining consistency in type hinting improves code readability.

### Merge Readiness
The pull request is not yet ready for merging. Addressing the error handling in the repository implementations and considering the efficiency of the `get_clusters_by_region` function are crucial. While the other comments are less critical, addressing them would improve the overall quality of the code. I am unable to approve this pull request, and recommend that another reviewer also reviews this code before merging.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T19:05:59Z

Review comments:
diff_hunk: @@ -0,0 +1,48 @@
+import logging
+from typing import List, Optional, Dict, Any
+from pymongo.errors import PyMongoError
+
+from src.domain.entities import Place, Region, Review
+from src.domain.repositories import PlaceRepository, RegionRepository, ReviewRepository
+from src.infrastructure.db import db
+
+logger = logging.getLogger(__name__)
+
+class MongoPlaceRepository(PlaceRepository):
+    def get(self, place_id: int) -> Optional[Place]:
+        try:
+            doc = db["place_info"].find_one(
+                {"place_id": place_id},
+                {"_id": 0}
+            )
+            return Place(**doc) if doc else None
+
+        except PyMongoError as e:
+            logger.error("MongoPlaceRepository.get failed for place_id=%s: %s", place_id, e, exc_info=True)
+            return None
path: src/infrastructure/repositories_impl.py
user: login: gemini-code-assist[bot]
body: ![high](https://img.shields.io/badge/priority-high-orange)

Instead of returning `None`, consider raising the `PyMongoError` to allow the service layer to handle the exception. This provides more flexibility in error handling and allows for potential retries or fallback mechanisms.

```suggestion
        except PyMongoError as e:
            logger.error("MongoPlaceRepository.get failed for place_id=%s: %s", place_id, e, exc_info=True)
            raise
```
created_at: 2025-05-03T19:05:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+import logging
+from typing import List, Optional, Dict, Any
+from pymongo.errors import PyMongoError
+
+from src.domain.entities import Place, Region, Review
+from src.domain.repositories import PlaceRepository, RegionRepository, ReviewRepository
+from src.infrastructure.db import db
+
+logger = logging.getLogger(__name__)
+
+class MongoPlaceRepository(PlaceRepository):
+    def get(self, place_id: int) -> Optional[Place]:
+        try:
+            doc = db["place_info"].find_one(
+                {"place_id": place_id},
+                {"_id": 0}
+            )
+            return Place(**doc) if doc else None
+
+        except PyMongoError as e:
+            logger.error("MongoPlaceRepository.get failed for place_id=%s: %s", place_id, e, exc_info=True)
+            return None
+
+class MongoRegionRepository(RegionRepository):
+    def get(self, adm_dong_code: int) -> Optional[Region]:
+        try:
+            doc = db["region_map"].find_one(
+                {"adm_dong_code": adm_dong_code},
+                {"_id": 0}
+            )
+            return Region(**doc) if doc else None
+
+        except PyMongoError as e:
+            logger.error("MongoRegionRepository.get failed for adm_dong_code=%s: %s", adm_dong_code, e, exc_info=True)
+            return None
path: src/infrastructure/repositories_impl.py
user: login: gemini-code-assist[bot]
body: ![high](https://img.shields.io/badge/priority-high-orange)

Similar to the `MongoPlaceRepository`, consider raising the `PyMongoError` instead of returning `None`.

```suggestion
        except PyMongoError as e:
            logger.error("MongoRegionRepository.get failed for adm_dong_code=%s: %s", adm_dong_code, e, exc_info=True)
            raise
```
created_at: 2025-05-03T19:05:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+import logging
+from typing import List, Optional, Dict, Any
+from pymongo.errors import PyMongoError
+
+from src.domain.entities import Place, Region, Review
+from src.domain.repositories import PlaceRepository, RegionRepository, ReviewRepository
+from src.infrastructure.db import db
+
+logger = logging.getLogger(__name__)
+
+class MongoPlaceRepository(PlaceRepository):
+    def get(self, place_id: int) -> Optional[Place]:
+        try:
+            doc = db["place_info"].find_one(
+                {"place_id": place_id},
+                {"_id": 0}
+            )
+            return Place(**doc) if doc else None
+
+        except PyMongoError as e:
+            logger.error("MongoPlaceRepository.get failed for place_id=%s: %s", place_id, e, exc_info=True)
+            return None
+
+class MongoRegionRepository(RegionRepository):
+    def get(self, adm_dong_code: int) -> Optional[Region]:
+        try:
+            doc = db["region_map"].find_one(
+                {"adm_dong_code": adm_dong_code},
+                {"_id": 0}
+            )
+            return Region(**doc) if doc else None
+
+        except PyMongoError as e:
+            logger.error("MongoRegionRepository.get failed for adm_dong_code=%s: %s", adm_dong_code, e, exc_info=True)
+            return None
+
+class MongoReviewRepository(ReviewRepository):
+    def list(self, place_id: int, limit: int) -> List[Review]:
+        try:
+            cursor = db["reviews"].find(
+                {"place_id": place_id},
+                {"_id": 0}
+            ).limit(limit)
+            return [Review(**doc) for doc in cursor]
+
+        except PyMongoError as e:
+            logger.error("MongoReviewRepository.list failed for place_id=%s: %s", place_id, e, exc_info=True)
+            return []
path: src/infrastructure/repositories_impl.py
user: login: gemini-code-assist[bot]
body: ![high](https://img.shields.io/badge/priority-high-orange)

Again, consider raising the `PyMongoError` instead of returning an empty list.

```suggestion
        except PyMongoError as e:
            logger.error("MongoReviewRepository.list failed for place_id=%s: %s", place_id, e, exc_info=True)
            raise
```
created_at: 2025-05-03T19:05:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+from fastapi import APIRouter, HTTPException, Depends, Query
+from typing import List
+from src.application.dtos import ClusterDTO, PlaceDTO, SituationDefinitionDTO
+from src.application.services import (
+    get_clusters_by_region,
+    get_places_by_region_and_cluster,
+    get_situation_definitions_by_place_id,
+)
+
+router = APIRouter(prefix="/regions", tags=["regions"])
+
+@router.get(
+    "/clusters",
+    response_model=List[ClusterDTO],
+    summary="행정구역별 상황 클러스터 리스트 조회",
+    description="district(시군구명)와 neighborhood(읍면동명)을 받아, 해당 지역에 속한 상황 클러스터 목록을 반환합니다."
+)
+def select_region_for_clusters(
+    district: str = Query(..., description="시군구명"),
+    neighborhood: str = Query(..., description="읍면동명"),
+    uc=Depends(get_clusters_by_region),
path: src/api/routers.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Consider renaming `uc` to something more descriptive like `get_clusters` to improve readability. This clarifies the purpose of the dependency.

```suggestion
    get_clusters=Depends(get_clusters_by_region),
```
created_at: 2025-05-03T19:05:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+from fastapi import APIRouter, HTTPException, Depends, Query
+from typing import List
+from src.application.dtos import ClusterDTO, PlaceDTO, SituationDefinitionDTO
+from src.application.services import (
+    get_clusters_by_region,
+    get_places_by_region_and_cluster,
+    get_situation_definitions_by_place_id,
+)
+
+router = APIRouter(prefix="/regions", tags=["regions"])
+
+@router.get(
+    "/clusters",
+    response_model=List[ClusterDTO],
+    summary="행정구역별 상황 클러스터 리스트 조회",
+    description="district(시군구명)와 neighborhood(읍면동명)을 받아, 해당 지역에 속한 상황 클러스터 목록을 반환합니다."
+)
+def select_region_for_clusters(
+    district: str = Query(..., description="시군구명"),
+    neighborhood: str = Query(..., description="읍면동명"),
+    uc=Depends(get_clusters_by_region),
+):
+    clusters = uc(district, neighborhood)
+    if not clusters:
+        raise HTTPException(status_code=404, detail="No clusters found")
+    return clusters
+
+
+@router.get(
+    "/places",
+    response_model=List[PlaceDTO],
+    summary="클러스터별 장소 리스트 조회",
+    description="district, neighborhood, cluster_id를 받아, 해당 클러스터에 속한 장소 목록을 반환합니다."
+)
+def select_region_and_cluster_for_places(
+    district: str = Query(..., description="시군구명"),
+    neighborhood: str = Query(..., description="읍면동명"),
+    cluster_id: int = Query(..., description="상황 클러스터 ID"),
+    uc=Depends(get_places_by_region_and_cluster),
path: src/api/routers.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Similar to the previous comment, renaming `uc` to `get_places` would enhance readability.

```suggestion
    get_places=Depends(get_places_by_region_and_cluster),
```
created_at: 2025-05-03T19:05:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,60 @@
+from fastapi import APIRouter, HTTPException, Depends, Query
+from typing import List
+from src.application.dtos import ClusterDTO, PlaceDTO, SituationDefinitionDTO
+from src.application.services import (
+    get_clusters_by_region,
+    get_places_by_region_and_cluster,
+    get_situation_definitions_by_place_id,
+)
+
+router = APIRouter(prefix="/regions", tags=["regions"])
+
+@router.get(
+    "/clusters",
+    response_model=List[ClusterDTO],
+    summary="행정구역별 상황 클러스터 리스트 조회",
+    description="district(시군구명)와 neighborhood(읍면동명)을 받아, 해당 지역에 속한 상황 클러스터 목록을 반환합니다."
+)
+def select_region_for_clusters(
+    district: str = Query(..., description="시군구명"),
+    neighborhood: str = Query(..., description="읍면동명"),
+    uc=Depends(get_clusters_by_region),
+):
+    clusters = uc(district, neighborhood)
+    if not clusters:
+        raise HTTPException(status_code=404, detail="No clusters found")
+    return clusters
+
+
+@router.get(
+    "/places",
+    response_model=List[PlaceDTO],
+    summary="클러스터별 장소 리스트 조회",
+    description="district, neighborhood, cluster_id를 받아, 해당 클러스터에 속한 장소 목록을 반환합니다."
+)
+def select_region_and_cluster_for_places(
+    district: str = Query(..., description="시군구명"),
+    neighborhood: str = Query(..., description="읍면동명"),
+    cluster_id: int = Query(..., description="상황 클러스터 ID"),
+    uc=Depends(get_places_by_region_and_cluster),
+):
+    places = uc(district, neighborhood, cluster_id)
+    if not places:
+        raise HTTPException(status_code=404, detail="No places found")
+    return places
+
+
+@router.get(
+    "/places/{place_id}",
+    response_model=List[SituationDefinitionDTO],
+    summary="장소별 리뷰 상황 설명 조회 (페이지네이션)",
+    description="place_id와 page를 받아, 해당 장소의 리뷰 상황 설명을 최대 10개씩 페이징해서 반환합니다."
+)
+def get_situation_definitions_of_place(
+    place_id: int,
+    page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
+    uc=Depends(get_situation_definitions_by_place_id),
path: src/api/routers.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Again, renaming `uc` to `get_situation_definitions` would improve readability.

```suggestion
    get_situation_definitions=Depends(get_situation_definitions_by_place_id),
```
created_at: 2025-05-03T19:05:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,122 @@
+from typing import List, Dict, Any
+from src.infrastructure.db import db
+import math
+
+def clean_nan(obj):
+    if isinstance(obj, dict):
+        return {k: clean_nan(v) for k, v in obj.items()}
+    elif isinstance(obj, list):
+        return [clean_nan(v) for v in obj]
+    elif isinstance(obj, float) and math.isnan(obj):
+        return None
+    else:
+        return obj
+
+# 구/동을 받아 해당 지역의 클러스터(cluster_id, cluster_name, places_count) 조회
+def get_clusters_by_region():
+    def uc(district: str, neighborhood: str) -> List[Dict[str, Any]]:
+        # 1) 행정동 코드 조회
+        region = db["region_map"].find_one(
+            {"district": district, "neighborhood": neighborhood},
+            {"adm_dong_code": 1, "_id": 0},
+        )
+        if not region:
+            return []
+
+        adm_dong = region["adm_dong_code"]
+
+        # 2) 해당 동의 모든 place_id 리스트 (인덱스가 있다면 매우 빠름)
+        place_ids = [
+            doc["place_id"]
+            for doc in db["place_info"].find(
+                {"adm_dong_code": adm_dong},
+                {"place_id": 1, "_id": 0},
+            )
+        ]
path: src/application/services.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

This approach retrieves all `place_id`s before grouping by cluster. For large regions, this could be inefficient. Consider using an aggregation pipeline to directly group by `situation_cluster` and `cluster_name` within the `place_info` collection to reduce the amount of data processed. This would involve joining `region_map` and `place_info` and then grouping by cluster.

```python
# TODO: Optimize this query to directly group by cluster within the region
        place_ids = [
            doc["place_id"]
            for doc in db["place_info"].find(
                {"adm_dong_code": adm_dong},
                {"place_id": 1, "_id": 0},
            )
        ]
```
created_at: 2025-05-03T19:05:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: seohyun-lee
created_at: 2025-05-03T19:05:22Z
body: /gemini summary
author_association: CONTRIBUTOR
user: login: gemini-code-assist[bot]
created_at: 2025-05-03T19:05:36Z
body: ## Summary of Changes
This pull request introduces the initial setup for a FastAPI project named 'where2eat-be', including project configuration, dependency management, and the development of service APIs. It sets up the basic structure for handling region-based data retrieval, place information, and review situation definitions, leveraging MongoDB for data storage. The PR also includes configurations for testing and development environments, as well as CORS middleware setup for handling cross-origin requests.

### Highlights
* **Project Setup**: Initializes a FastAPI project with configurations for MongoDB connection, CORS middleware, and environment variables.
* **API Development**: Develops service APIs for retrieving clusters by region, places by region and cluster, and situation definitions by place ID, including request parameter validation and error handling.
* **Data Transfer Objects (DTOs)**: Defines DTOs for transferring data related to clusters, places, and situation definitions, ensuring type safety and data structure consistency.
* **Database Interaction**: Implements services to interact with MongoDB, including data retrieval using aggregation pipelines and indexing for optimized performance.
* **Testing Configuration**: Configures pytest with environment variable overrides for testing MongoDB interactions, and sets up functional tests for the API endpoints.

### Changelog
<details>
<summary>Click here to see the changelog</summary>

* **.gitignore**
  * Added .idea, .DS_Store, and .vscode to the .gitignore file to exclude IDE and OS specific files from version control.
* **.python-version**
  * Added a .python-version file specifying Python 3.11 as the project's Python version.
* **pyproject.toml**
  * Created a pyproject.toml file to define project metadata and dependencies, including FastAPI, pydantic-settings, pymongo, pytest, python-dotenv, and uvicorn.
* **src/api/routers.py**
  * Implemented API routes for retrieving clusters by region, places by region and cluster, and situation definitions by place ID.
  * Added request parameter validation using FastAPI's Query parameters.
  * Implemented error handling using HTTPException for cases where no data is found.
  * Utilized Depends to inject service functions as dependencies.
* **src/application/dtos.py**
  * Defined DTOs (ClusterDTO, PlaceDTO, SituationDefinitionDTO) using pydantic's BaseModel to structure the data transferred by the API.
* **src/application/services.py**
  * Implemented service functions (get_clusters_by_region, get_places_by_region_and_cluster, get_situation_definitions_by_place_id) to handle data retrieval from MongoDB.
  * Utilized MongoDB's aggregation pipeline for complex data retrieval and grouping.
  * Implemented pagination for retrieving situation definitions by place ID.
  * Added clean_nan function to handle NaN values from MongoDB.
* **src/core/config.py**
  * Created a Settings class using pydantic-settings to manage environment variables from a .env file.
* **src/domain/entities.py**
  * Defined data entities (Place, Region, Review) using pydantic's BaseModel to represent the structure of data stored in MongoDB.
* **src/domain/repositories.py**
  * Defined repository interfaces (PlaceRepository, RegionRepository, ReviewRepository) to abstract data access logic.
* **src/infrastructure/db.py**
  * Initialized a MongoDB client and database connection using settings from the configuration file.
* **src/infrastructure/repositories_impl.py**
  * Implemented repository classes (MongoPlaceRepository, MongoRegionRepository, MongoReviewRepository) to interact with MongoDB and retrieve data.
  * Implemented error handling for MongoDB operations using try-except blocks and logging.
* **src/main.py**
  * Created a FastAPI application instance with CORS middleware configured to allow requests from specified origins.
  * Included the API router in the application.
  * Added a root endpoint and a favicon endpoint.
* **tests/conftest.py**
  * Configured pytest to add the project root to the module path and override environment variables for testing.
* **tests/functional/test_regions.py**
  * Implemented functional tests for the API endpoints using FastAPI's TestClient.
  * Utilized pytest fixtures to override dependencies with mock implementations for testing purposes.
  * Tested successful and unsuccessful scenarios for retrieving clusters, places, and situation definitions.
* **uv.lock**
  * Added uv.lock file to specify the exact versions of the dependencies used in the project.

</details>

### Activity
* seohyun-lee created the pull request with the title 'Feat: FastAPI 프로젝트 세팅, 서비스 API 개발' and description 'FastAPI 프로젝트 세팅, 서비스 API를 개발했습니다.'
* seohyun-lee requested a review using the '/gemini review' command.
* seohyun-lee requested a summary using the '/gemini summary' command.
* seohyun-lee requested another summary using the '/gemini summary' command.

author_association: CONTRIBUTOR
user: login: seohyun-lee
created_at: 2025-05-03T19:05:43Z
body: /gemini review
author_association: CONTRIBUTOR