diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0379813b..c4ceddfa 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ - -## :메모: 작업 내용 + +## 📝 작업 내용 -## :링크: 관련 이슈 - + +--- + +## 🔗 관련 이슈 + - Closes #이슈번호 - Related to #이슈번호 -## :말풍선: 추가 요청사항 + +--- + +## 💬 추가 요청사항 -## :흰색_확인_표시: 체크리스트 + +--- + +## ✅ 체크리스트 + ### 코드 품질 - [ ] 커밋 컨벤션 준수 (feat/fix/docs/refactor 등) - [ ] 불필요한 코드/주석 제거 + ### 테스트 - [ ] 로컬 환경에서 동작 확인 완료 - [ ] 기존 기능에 영향 없음 확인 + ### 배포 준비 - [ ] 환경변수 추가/변경사항 문서화 - [ ] DB 마이그레이션 필요 여부 확인 -- [ ] 배포 시 주의사항 없음 \ No newline at end of file +- [ ] 배포 시 주의사항 없음 diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index 42ac78b0..3f1a3b76 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -24,6 +24,7 @@ permissions: jobs: spotless-check: + if: github.event.pull_request.draft == false name: Lint Check runs-on: ubuntu-latest @@ -73,14 +74,15 @@ jobs: run: ./gradlew build -x test working-directory: apps/user-service -# - name: Run Tests -# run: | -# if [ "${{ github.base_ref }}" == "main" ]; then -# ./gradlew test -# else -# ./gradlew prTest -# fi -# working-directory: apps/user-service + - name: Run Tests + run: | + if [ "${{ github.base_ref }}" == "main" ]; then + ./gradlew allTests + else + ./gradlew test + fi + working-directory: apps/user-service + - name: Upload build artifacts if: matrix.java-version == '21' && github.ref == 'refs/heads/main' && github.event_name == 'push' uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 30de240b..06344943 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea logs -*.log \ No newline at end of file +*.log +*.env* \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e69de29b..5cfc8727 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Spring +## Project root +- IDE에서 Final-4team-icebang를 root로 열어야 합니다 +- 현재 docker container가 없다면 docker container create 후 spring이 bootstrap되지 않습니다 + - 번거롭겠지만 spring boot restart 부탁드립니다. \ No newline at end of file diff --git a/apps/pre-processing-service/Dockerfile b/apps/pre-processing-service/Dockerfile new file mode 100644 index 00000000..073dea33 --- /dev/null +++ b/apps/pre-processing-service/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim AS builder +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +RUN curl -sSL https://install.python-poetry.org | python3 - +ENV PATH="/root/.local/bin:$PATH" +RUN poetry config virtualenvs.create false +COPY pyproject.toml poetry.lock ./ +RUN poetry install --no-root + +FROM python:3.11-slim AS final +WORKDIR /app +# site-packages + 콘솔 스크립트(gunicorn/uvicorn) 함께 복사 +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin +COPY ./app ./app +EXPOSE 8000 +CMD ["gunicorn", "-w", "2", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "app.main:app"] diff --git a/apps/pre-processing-service/app/__init__.py b/apps/pre-processing-service/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/api/__init__.py b/apps/pre-processing-service/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/api/endpoints/blog.py b/apps/pre-processing-service/app/api/endpoints/blog.py new file mode 100644 index 00000000..6a771cae --- /dev/null +++ b/apps/pre-processing-service/app/api/endpoints/blog.py @@ -0,0 +1,67 @@ +from ...errors.CustomException import * +from fastapi import APIRouter + +from ...model.schemas import * +from app.service.blog.tistory_blog_post_service import TistoryBlogPostService +from app.service.blog.naver_blog_post_service import NaverBlogPostService + +# 이 파일만의 독립적인 라우터를 생성합니다. +router = APIRouter() + +@router.get("/") +async def root(): + return {"message": "blog API"} + +@router.post("/rag/create", response_model=ResponseBlogCreate) +async def rag_create(request: RequestBlogCreate): + """ + RAG 기반 블로그 콘텐츠 생성 + """ + return {"message": "blog API"} + +@router.post("/publish", response_model=ResponseBlogPublish) +async def publish(request: RequestBlogPublish): + """ + 생성된 블로그 콘텐츠 배포 + 네이버 블로그와 티스토리 블로그를 지원 + 현재는 생성된 콘텐츠가 아닌, 임의의 제목,내용,태그를 배포 + :param request: RequestBlogPublish + :return: ResponseBlogPublish + """ + + if request.tag == "naver": + naver_service = NaverBlogPostService() + result = naver_service.post_content( + title=request.title, + content=request.content, + tags=request.tags + ) + + if not result: + raise CustomException("네이버 블로그 포스팅에 실패했습니다.", status_code=500) + return ResponseBlogPublish( + job_id= 1, + schedule_id= 1, + schedule_his_id= 1, + status="200", + metadata=result + ) + + else: + tistory_service = TistoryBlogPostService() + result = tistory_service.post_content( + title=request.title, + content=request.content, + tags=request.tags + ) + + if not result: + raise CustomException("티스토리 블로그 포스팅에 실패했습니다.", status_code=500) + + return ResponseBlogPublish( + job_id= 1, + schedule_id= 1, + schedule_his_id= 1, + status="200", + metadata=result + ) \ No newline at end of file diff --git a/apps/pre-processing-service/app/api/endpoints/embedding.py b/apps/pre-processing-service/app/api/endpoints/embedding.py deleted file mode 100644 index 8a8d1d6f..00000000 --- a/apps/pre-processing-service/app/api/endpoints/embedding.py +++ /dev/null @@ -1,12 +0,0 @@ -# app/api/endpoints/embedding.py -from fastapi import APIRouter -from app.decorators.logging import log_api_call -from ...errors.CustomException import * -from fastapi import APIRouter - -# 이 파일만의 독립적인 라우터를 생성합니다. -router = APIRouter() - -@router.get("/") -async def root(): - return {"message": "Items API"} \ No newline at end of file diff --git a/apps/pre-processing-service/app/api/endpoints/keywords.py b/apps/pre-processing-service/app/api/endpoints/keywords.py new file mode 100644 index 00000000..888ff0a0 --- /dev/null +++ b/apps/pre-processing-service/app/api/endpoints/keywords.py @@ -0,0 +1,35 @@ +# app/api/endpoints/keywords.py +from ...service.keyword_service import keyword_search + +from fastapi import APIRouter +from ...errors.CustomException import * +from ...model.schemas import RequestNaverSearch, ResponseNaverSearch + +# 이 파일만의 독립적인 라우터를 생성합니다. +router = APIRouter() + +@router.get("/") +async def root(): + return {"message": "keyword API"} + +@router.post("/search",response_model=ResponseNaverSearch) +async def search(request: RequestNaverSearch): + """ + 이 엔드포인트는 아래와 같은 JSON 요청을 받습니다. + RequestBase와 RequestNaverSearch의 모든 필드를 포함해야 합니다. + { + "job_id": "job-123", + "schedule_id": "schedule-456", + "schedule_his_id": 789, + "tag": "fastapi", + "category": "tech", + "start_date": "2025-09-01T12:00:00", + "end_date": "2025-09-02T15:00:00" + } + """ + response_data= await keyword_search(request) + return response_data + +@router.post("/ssadagu/validate",response_model=ResponseNaverSearch) +async def ssadagu_validate(request: RequestNaverSearch): + return ResponseNaverSearch() diff --git a/apps/pre-processing-service/app/api/endpoints/processing.py b/apps/pre-processing-service/app/api/endpoints/processing.py deleted file mode 100644 index 51c8ff27..00000000 --- a/apps/pre-processing-service/app/api/endpoints/processing.py +++ /dev/null @@ -1,12 +0,0 @@ -# app/api/endpoints/embedding.py -from fastapi import APIRouter -from app.decorators.logging import log_api_call -from ...errors.CustomException import * -from fastapi import APIRouter - -# 이 파일만의 독립적인 라우터를 생성합니다. -router = APIRouter() - -@router.get("/") -async def root(): - return {"message": "사용자API"} \ No newline at end of file diff --git a/apps/pre-processing-service/app/api/endpoints/product.py b/apps/pre-processing-service/app/api/endpoints/product.py new file mode 100644 index 00000000..4e8c6682 --- /dev/null +++ b/apps/pre-processing-service/app/api/endpoints/product.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Request, HTTPException +from app.decorators.logging import log_api_call +from ...errors.CustomException import InvalidItemDataException, ItemNotFoundException +from ...service.crawl_service import crawl_product_detail +from ...service.search_service import search_products +from ...service.match_service import match_products +from ...service.similarity_service import select_product_by_similarity +from ...model.schemas import * + +router = APIRouter() + +@router.get("/") +async def root(): + return {"message": "product API"} + +@router.post("/search", response_model=ResponseSadaguSearch) +async def search(request: RequestSadaguSearch): + """ + 상품 검색 엔드포인트 + """ + return await search_products(request) + +@router.post("/match", response_model=ResponseSadaguMatch) +async def match(request: RequestSadaguMatch): + """ + 상품 매칭 엔드포인트 + """ + return match_products(request) + +@router.post("/similarity", response_model=ResponseSadaguSimilarity) +async def similarity(request: RequestSadaguSimilarity): + """ + 유사도 분석 엔드포인트 + """ + return select_product_by_similarity(request) + +@router.post("/crawl", response_model=ResponseSadaguCrawl) +async def crawl(request: Request, body: RequestSadaguCrawl): + """ + 상품 상세 정보 크롤링 엔드포인트 + """ + try: + result = await crawl_product_detail(body) + return result + except InvalidItemDataException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except ItemNotFoundException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/apps/pre-processing-service/app/api/router.py b/apps/pre-processing-service/app/api/router.py index a1c064cc..683f42a7 100644 --- a/apps/pre-processing-service/app/api/router.py +++ b/apps/pre-processing-service/app/api/router.py @@ -1,18 +1,21 @@ # app/api/router.py from fastapi import APIRouter -from .endpoints import embedding, processing,test +from .endpoints import keywords, blog, product, test from ..core.config import settings api_router = APIRouter() # embedding API URL -api_router.include_router(embedding.router, prefix="/emb", tags=["Embedding"]) +api_router.include_router(keywords.router, prefix="/keywords", tags=["keyword"]) # processing API URL -api_router.include_router(processing.router, prefix="/prc", tags=["Processing"]) +api_router.include_router(blog.router, prefix="/blogs", tags=["blog"]) -#모듈 테스터를 위한 endpoint -api_router.include_router(test.router, prefix="/test", tags=["Test"]) +#상품 API URL +api_router.include_router(product.router, prefix="/products", tags=["product"]) + +#모듈 테스터를 위한 endpoint -> 추후 삭제 예정 +api_router.include_router(test.router, prefix="/tests", tags=["Test"]) @api_router.get("/") async def root(): diff --git a/apps/pre-processing-service/app/config/logging/RepositoryLoggerMiddleware.py b/apps/pre-processing-service/app/config/logging/RepositoryLoggerMiddleware.py deleted file mode 100644 index 703834a6..00000000 --- a/apps/pre-processing-service/app/config/logging/RepositoryLoggerMiddleware.py +++ /dev/null @@ -1,117 +0,0 @@ -import time -from typing import Dict, Any, List -from fastapi import Request -from loguru import logger -from contextvars import ContextVar - -trace_id_context: ContextVar[str] = ContextVar('trace_id', default="NO_TRACE_ID") - -class RepositoryLoggingDependency: - """ - 레포지토리 로깅을 위한 의존성 클래스 - :param repository_type: 레포지토리 유형 (예: "VECTOR_DB", "RDB", "REDIS") - :param track_params: 추적할 매개변수 이름 목록 - """ - - def __init__(self, repository_type: str, track_params: List[str] = None): - self.repository_type = repository_type - self.track_params = track_params or [] - - async def __call__(self, request: Request): - """ - 의존성 주입 시 호출되는 메서드 - :param request: FastAPI Request 객체 - :return: 레포지토리 유형과 추출된 매개변수 딕셔너리 - """ - trace_id = trace_id_context.get("NO_TRACE_ID") - start_time = time.time() - - # 파라미터 추출 - params = await self._extract_params(request) - param_str = "" - if params: - param_strs = [f"{k}={v}" for k, v in params.items()] - param_str = " " + " ".join(param_strs) - - logger.info(f"[{self.repository_type}_START] trace_id={trace_id}{param_str}") - - # 응답 시 사용할 정보를 request.state에 저장 - request.state.repository_type = self.repository_type - request.state.start_time = start_time - request.state.param_str = param_str - - return {"repository_type": self.repository_type, "params": params} - - async def _extract_params(self, request: Request) -> Dict[str, Any]: - """ - 요청에서 추적 파라미터 추출 - :param request: FastAPI Request 객체 - :return: 추출된 매개변수 딕셔너리 - """ - params = {} - - try: - # Query Parameters 추출 - for key, value in request.query_params.items(): - if key in self.track_params: - params[key] = value - - # JSON Body 추출 - try: - json_body = await request.json() - if json_body: - for key, value in json_body.items(): - if key in self.track_params: - if isinstance(value, str) and len(value) > 50: - params[f"{key}_length"] = len(value) - elif isinstance(value, list): - params[f"{key}_count"] = len(value) - else: - params[key] = value - except: - pass - except: - pass - - return params - - -# 레포지토리별 의존성 인스턴스 생성 -vector_db_dependency = RepositoryLoggingDependency("VECTOR_DB", ["query", "embeddings", "top_k", "collection", "filters"]) -rdb_dependency = RepositoryLoggingDependency("RDB", ["table", "where_clause", "limit", "data"]) -redis_dependency = RepositoryLoggingDependency("REDIS", ["key", "value", "ttl", "pattern"]) -elasticsearch_dependency = RepositoryLoggingDependency("ELASTICSEARCH", ["index", "query", "size", "document"]) - - -# 응답 로깅을 위한 의존성 -async def log_repository_response(request: Request): - """ - 레포지토리 응답 시 성공 로그 기록 - :param request: FastAPI Request 객체 - """ - if hasattr(request.state, 'repository_type'): - trace_id = trace_id_context.get("NO_TRACE_ID") - duration = time.time() - request.state.start_time - logger.info( - f"[{request.state.repository_type}_SUCCESS] trace_id={trace_id} execution_time={duration:.4f}s{request.state.param_str}") - return None - - -""" -라우터 예시 -@router.post("/search") -async def vector_search( - query: str, - top_k: int = 10, - request: Request = None, - _: None = Depends(vector_db_dependency), # 직접 의존성 주입 - __: None = Depends(log_repository_response) -): - -또는 라우터 레벨에서: -vector_router = APIRouter( - prefix="/vector", - tags=["vector"], - dependencies=[Depends(vector_db_dependency)] -) -""" \ No newline at end of file diff --git a/apps/pre-processing-service/app/config/logging/ServiceLoggerMiddleware.py b/apps/pre-processing-service/app/config/logging/ServiceLoggerMiddleware.py deleted file mode 100644 index 5e4816a1..00000000 --- a/apps/pre-processing-service/app/config/logging/ServiceLoggerMiddleware.py +++ /dev/null @@ -1,109 +0,0 @@ -import time -from typing import Dict, Any, List -from fastapi import Request -from loguru import logger -from contextvars import ContextVar - -trace_id_context: ContextVar[str] = ContextVar('trace_id', default="NO_TRACE_ID") - - -class ServiceLoggingDependency: - """ - 서비스 로깅을 위한 의존성 클래스 - :param service_type: 서비스 유형 (예: "CHUNKING", "PARSING", "EMBEDDING") - :param track_params: 추적할 매개변수 이름 목록 - """ - - def __init__(self, service_type: str, track_params: List[str] = None): - self.service_type = service_type - self.track_params = track_params or [] - - async def __call__(self, request: Request): - """ - 의존성 주입 시 호출되는 메서드 - :param request: FastAPI Request 객체 - :return: 서비스 유형과 추출된 매개변수 딕셔너리 - """ - trace_id = trace_id_context.get("NO_TRACE_ID") - start_time = time.time() - - # 파라미터 추출 - params = await self._extract_params(request) - param_str = "" - if params: - param_strs = [f"{k}={v}" for k, v in params.items()] - param_str = " " + " ".join(param_strs) - - logger.info(f"[{self.service_type}_START] trace_id={trace_id}{param_str}") - - # 응답 시 사용할 정보를 request.state에 저장 - request.state.service_type = self.service_type - request.state.start_time = start_time - request.state.param_str = param_str - - return {"service_type": self.service_type, "params": params} - - async def _extract_params(self, request: Request) -> Dict[str, Any]: - """ - 요청에서 추적 파라미터 추출 - :param request: FastAPI Request 객체 - :return: 추출된 매개변수 딕셔너리 - """ - params = {} - - try: - # Query Parameters 추출 - for key, value in request.query_params.items(): - if key in self.track_params: - params[key] = value - - # JSON Body 추출 - try: - json_body = await request.json() - if json_body: - for key, value in json_body.items(): - if key in self.track_params: - if isinstance(value, str) and len(value) > 50: - params[f"{key}_length"] = len(value) - elif isinstance(value, list): - params[f"{key}_count"] = len(value) - else: - params[key] = value - except: - pass - except: - pass - - return params - - -# 서비스별 의존성 인스턴스 생성 -chunking_dependency = ServiceLoggingDependency("CHUNKING", ["text", "chunk_size", "overlap"]) -parsing_dependency = ServiceLoggingDependency("PARSING", ["file_path", "file_type", "document"]) -embedding_dependency = ServiceLoggingDependency("EMBEDDING", ["chunks", "model_name", "batch_size"]) - -# 응답 로깅을 위한 의존성 -async def log_service_response(request: Request): - """ - 서비스 응답 시 성공 로그 기록 - :param request: FastAPI Request 객체 - """ - if hasattr(request.state, 'service_type'): - trace_id = trace_id_context.get("NO_TRACE_ID") - duration = time.time() - request.state.start_time - logger.info( - f"[{request.state.service_type}_SUCCESS] trace_id={trace_id} execution_time={duration:.4f}s{request.state.param_str}") - return None - -""" -라우터 예시 -@router.post("/chunk") -async def chunk_text( - text: str, - chunk_size: int = 100, - overlap: int = 20, - request: Request = None, - _: None = Depends(chunking_dependency), # 직접 의존성 주입 - __: None = Depends(log_service_response) -): -""" \ No newline at end of file diff --git a/apps/pre-processing-service/app/core/__init__.py b/apps/pre-processing-service/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/core/config.py b/apps/pre-processing-service/app/core/config.py index 06b55aa2..52930483 100644 --- a/apps/pre-processing-service/app/core/config.py +++ b/apps/pre-processing-service/app/core/config.py @@ -1,8 +1,65 @@ -from pydantic_settings import BaseSettings +# pydantic_settings에서 SettingsConfigDict를 추가로 import 합니다. +from pydantic_settings import BaseSettings, SettingsConfigDict import os +import platform +import subprocess from typing import Optional +def detect_mecab_dicdir() -> Optional[str]: + """MeCab 사전 경로 자동 감지""" + + # 1. mecab-config 명령어로 사전 경로 확인 (가장 정확한 방법) + try: + result = subprocess.run(['mecab-config', '--dicdir'], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + dicdir = result.stdout.strip() + if os.path.exists(dicdir): + print(f"mecab-config에서 사전 경로 발견: {dicdir}") + return dicdir + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + pass + + # 2. 플랫폼별 일반적인 경로들 확인 + system = platform.system().lower() + + if system == "darwin": # macOS + candidate_paths = [ + "/opt/homebrew/lib/mecab/dic/mecab-ko-dic", # Apple Silicon + "/usr/local/lib/mecab/dic/mecab-ko-dic", # Intel Mac + "/opt/homebrew/lib/mecab/dic/mecab-ipadic", # 기본 사전 + "/usr/local/lib/mecab/dic/mecab-ipadic" + ] + elif system == "linux": + candidate_paths = [ + "/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ko-dic", + "/usr/lib/mecab/dic/mecab-ko-dic", + "/usr/local/lib/mecab/dic/mecab-ko-dic", + "/usr/share/mecab/dic/mecab-ko-dic", + "/usr/lib/mecab/dic/mecab-ipadic", + "/usr/local/lib/mecab/dic/mecab-ipadic" + ] + elif system == "windows": + candidate_paths = [ + "C:/Program Files/MeCab/dic/mecab-ko-dic", + "C:/mecab/dic/mecab-ko-dic", + "C:/Program Files/MeCab/dic/mecab-ipadic" + ] + else: + candidate_paths = [] + + # 경로 존재 여부 확인 + for path in candidate_paths: + if os.path.exists(path): + # dicrc 파일 존재 확인 (실제 사전인지 검증) + dicrc_path = os.path.join(path, "dicrc") + if os.path.exists(dicrc_path): + print(f"플랫폼 기본 경로에서 사전 발견: {path}") + return path + + return None + # 공통 설정을 위한 BaseSettings class BaseSettingsConfig(BaseSettings): @@ -12,24 +69,51 @@ class BaseSettingsConfig(BaseSettings): db_user: str db_pass: str db_name: str - env_name: str = "dev" + env_name: str = ".dev" + + # MeCab 사전 경로 (자동 감지) + mecab_path: Optional[str] = None + + # 외부 서비스 계정 정보 + naver_id: Optional[str] = None + naver_password: Optional[str] = None + tistory_id: Optional[str] = None + tistory_password: Optional[str] = None + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # mecab_path가 설정되지 않았으면 자동 감지 + if not self.mecab_path: + self.mecab_path = detect_mecab_dicdir() + if not self.mecab_path: + print("MeCab 사전 경로를 찾을 수 없어 기본 설정으로 실행합니다.") @property def db_url(self) -> str: """개별 필드를 사용하여 DB URL을 동적으로 생성""" return f"postgresql://{self.db_user}:{self.db_pass}@{self.db_host}:{self.db_port}/{self.db_name}" - class Config: - env_file = ['.env'] + model_config = SettingsConfigDict(env_file=['.env']) # 환경별 설정 클래스 class DevSettings(BaseSettingsConfig): - class Config: - env_file = ['.env', 'dev.env'] + model_config = SettingsConfigDict(env_file=['.env', '.dev.env']) class PrdSettings(BaseSettingsConfig): - class Config: - env_file = ['.env', 'prd.env'] + model_config = SettingsConfigDict(env_file=['.env', '.prd.env']) + +def get_settings() -> BaseSettingsConfig: + """환경 변수에 따라 적절한 설정 객체를 반환하는 함수""" + mode = os.getenv("MODE", "dev") + if mode == "dev": + return DevSettings() + elif mode == "prd": + return PrdSettings() + else: + raise ValueError(f"Invalid MODE environment variable: {mode}") + +settings = get_settings() \ No newline at end of file diff --git a/apps/pre-processing-service/app/db/AsyncPostgreSQLManager.py b/apps/pre-processing-service/app/db/AsyncPostgreSQLManager.py new file mode 100644 index 00000000..a6152755 --- /dev/null +++ b/apps/pre-processing-service/app/db/AsyncPostgreSQLManager.py @@ -0,0 +1,189 @@ +import asyncpg +import os +import threading + +from contextlib import asynccontextmanager + + +class AsyncPostgreSQLManager: + """ + 비동기 PostgreSQL 매니저 클래스 (싱글톤 패턴) + 1. PostgreSQL 데이터베이스 연결 및 관리 + 2. 커넥션 풀링 지원 + 3. 쿼리 실행 및 결과 반환 + 4. 트랜잭션 관리 + 5. 애플리케이션 종료 시 전체 풀 종료 + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + """ + 싱글톤 패턴 구현 + 스레드 안전성을 위해 Lock 사용 + """ + + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super(AsyncPostgreSQLManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + """ + PostgreSQL 매니저 초기화 + 1. 데이터베이스 연결 설정 + 2. 환경 변수에서 데이터베이스 설정 로드 + """ + + # 이미 초기화된 경우 재초기화 방지 + if self._initialized: + return + + self._pool = None + self._config = { + 'host': os.getenv('DB_HOST', '52.79.235.214'), + 'port': int(os.getenv('DB_PORT', 5432)), + 'database': os.getenv('DB_NAME', 'pre_process'), + 'user': os.getenv('DB_USER', 'postgres'), + 'password': os.getenv('DB_PASSWORD', 'qwer1234') + } + self._initialized = True + + @classmethod + def get_instance(cls): + """ + 싱글톤 인스턴스 반환 + :return: AsyncPostgreSQLManager 인스턴스 + """ + + return cls() + + async def init_pool(self, min_size=5, max_size=20): + """ + 비동기 커넥션 풀 초기화 + 애플리케이션 시작 시 단 한번만 호출되어야 한다. + :param min_size: 최소 커넥션 수 + :param max_size: 최대 커넥션 수 + :return: 커넥션 풀 객체 + """ + + if self._pool is None: + self._pool = await asyncpg.create_pool( + min_size=min_size, + max_size=max_size, + **self._config + ) + return self._pool + + async def _get_connection(self): + """ + 커넥션 풀에서 커넥션 가져오기 + :return: 커넥션 객체 + """ + + if self._pool is None: + await self.init_pool() + return await self._pool.acquire() + + @asynccontextmanager + async def get_connection(self): + """ + 커넥션 컨텍스트 매니저 + :return: 커넥션 객체 + """ + + if self._pool is None: + await self.init_pool() + conn = await self._pool.acquire() + transaction = None + + try: + transaction = conn.transaction() + await transaction.start() + yield conn + await transaction.commit() + except Exception as e: + if transaction: + await transaction.rollback() + raise e + finally: + await self._pool.release(conn) + + async def execute_query(self, sql, *params, fetch=False): + """ + 쿼리 실행 + :param sql: 실행할 SQL 쿼리 + :param params: 쿼리 파라미터 + :param fetch: 결과를 가져올지 여부 + :return: 쿼리 결과 (fetch가 True인 경우) + """ + + async with self.get_connection() as conn: + if fetch: + return await conn.fetch(sql, *params) + else: + return await conn.execute(sql, *params) + + async def fetch_one(self, sql, *params): + """ + 단일 행 조회 + :param sql: SQL 쿼리 + :param params: 쿼리 파라미터 + :return: 단일 행 결과 + """ + + async with self.get_connection() as conn: + return await conn.fetchrow(sql, *params) + + async def fetch_all(self, sql, *params): + """ + 전체 행 조회 + :param sql: SQL 쿼리 + :param params: 쿼리 파라미터 + :return: 전체 행 결과 + """ + + async with self.get_connection() as conn: + return await conn.fetch(sql, *params) + + async def execute(self, sql, *params): + """ + 쿼리 실행 (INSERT, UPDATE, DELETE 등) + :param sql: SQL 쿼리 + :param params: 쿼리 파라미터 + :return: 실행 결과 상태 + """ + + async with self.get_connection() as conn: + return await conn.execute(sql, *params) + + async def execute_many(self, sql, params_list): + """ + 배치 쿼리 실행 + :param sql: SQL 쿼리 + :param params_list: 파라미터 리스트 + :return: 실행 결과 상태 + """ + + async with self.get_connection() as conn: + return await conn.executemany(sql, params_list) + + async def close_pool(self): + """ + 애플리케이션 종료 시 전체 풀 종료 + :return: None + """ + + if self._pool: + await self._pool.close() + self._pool = None + print("비동기 DB 연결 풀 전체 종료") + +""" +# 사용 예시 +init_pool() - 애플리케이션 시작 시 단 한번만 호출 (main.py에서 실행, early startup) + +""" \ No newline at end of file diff --git a/apps/pre-processing-service/app/db/MariadbManager.py b/apps/pre-processing-service/app/db/MariadbManager.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/db/PostgreSQLManager.py b/apps/pre-processing-service/app/db/PostgreSQLManager.py new file mode 100644 index 00000000..606f7b5c --- /dev/null +++ b/apps/pre-processing-service/app/db/PostgreSQLManager.py @@ -0,0 +1,142 @@ +from contextlib import contextmanager + +import psycopg2 +import psycopg2.pool +import os +import threading + +class PostgreSQLManager: + """ + PostgreSQL 매니저 클래스 + 1. PostgreSQL 데이터베이스 연결 및 관리 + 2. 커넥션 풀링 지원 + 3. 쿼리 실행 및 결과 반환 + 4. 애플리케이션 종료 시 전체 풀 종료 + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + """ + 싱글톤 패턴 구현 + 스레드 안전성을 위해 Lock 사용 + """ + + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super(PostgreSQLManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + """ + PostgreSQL 매니저 초기화 + 1. 데이터베이스 연결 설정 + 2. 환경 변수에서 데이터베이스 설정 로드 + """ + + if self._initialized: + return + + self._pool = None + self._config = { + 'host': os.getenv('DB_HOST', '52.79.235.214'), + 'port': int(os.getenv('DB_PORT', '5432')), + 'database': os.getenv('DB_NAME', 'pre_process'), + 'user': os.getenv('DB_USER', 'postgres'), + 'password': os.getenv('DB_PASSWORD', 'qwer1234') + } + self._initialized = True + + @classmethod + def get_instance(cls): + """ + 싱글톤 인스턴스 반환 + :return: PostgreSQLManager 인스턴스 + """ + + return cls() + + def _init_pool(self, min_conn=5, max_conn=20, **custom_config): + """ + 커넥션 풀 초기화 + :param min_conn: 최소 커넥션 수 + :param max_conn: 최대 커넥션 수 + :param custom_config: 커스텀 데이터베이스 설정 + :return: None + """ + + if self._pool is None: + config = {**self._config, **custom_config} + self._pool = psycopg2.pool.ThreadedConnectionPool( + min_conn, max_conn, **config + ) + + def _get_connection(self): + """ + 커넥션 풀에서 커넥션 가져오기 + :return: 커넥션 객체 + """ + + if self._pool is None: + self._init_pool() + return self._pool.getconn() + + @contextmanager + def get_cursor(self): + """ + 커서 컨텍스트 매니저 + :return: 커서 객체 + """ + + conn = self._get_connection() + cursor = None + try: + cursor = conn.cursor() + yield cursor + conn.commit() + except Exception as e: + conn.rollback() + raise e + finally: + if cursor: + cursor.close() + self._pool.putconn(conn) + + def execute_query(self, sql, params=None, fetch=False): + """ + 쿼리 실행 + :param sql: 실행할 SQL 쿼리 + :param params: 쿼리 파라미터 + :param fetch: 결과를 가져올지 여부 + :return: 쿼리 결과 (fetch가 True인 경우) + """ + + with self.get_cursor() as cursor: + cursor.execute(sql, params) + if fetch: + return cursor.fetchall() + + def close_pool(self): + """ + 애플리케이션 종료 시 전체 풀 종료 + :return: None + """ + + if self._pool: + self._pool.closeall() + self._pool = None + print("DB 연결 풀 전체 종료") + +""" +# get_cursor 사용 예시 : 리소스 자동 정리 +try: + with db.get_cursor() as cursor: + cursor.execute("INSERT INTO users (name) VALUES (%s)", ("John",)) + cursor.execute("INVALID SQL") # 에러 발생 +except Exception as e: + print(f"에러 발생: {e}") + # 자동으로 롤백, 커서 닫기, 커넥션 반환 수행 +""" \ No newline at end of file diff --git a/apps/pre-processing-service/app/db/__init__.py b/apps/pre-processing-service/app/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/errors/BlogPostingException.py b/apps/pre-processing-service/app/errors/BlogPostingException.py new file mode 100644 index 00000000..d0b360a8 --- /dev/null +++ b/apps/pre-processing-service/app/errors/BlogPostingException.py @@ -0,0 +1,79 @@ +from app.errors.CustomException import CustomException +from typing import List, Optional + +class BlogLoginException(CustomException): + """ + 블로그 로그인 실패 예외 + @:param platform: 로그인하려는 플랫폼 (네이버, 티스토리 등) + @:param reason: 로그인 실패 이유 + """ + def __init__(self, platform: str, reason: str = "인증 정보가 올바르지 않습니다"): + super().__init__( + status_code=401, + detail=f"{platform} 로그인에 실패했습니다. {reason}", + code="BLOG_LOGIN_FAILED" + ) + +class BlogPostPublishException(CustomException): + """ + 블로그 포스트 발행 실패 예외 + @:param platform: 발행하려는 플랫폼 + @:param reason: 발행 실패 이유 + """ + def __init__(self, platform: str, reason: str = "포스트 발행 중 오류가 발생했습니다"): + super().__init__( + status_code=422, + detail=f"{platform} 포스트 발행에 실패했습니다. {reason}", + code="BLOG_POST_PUBLISH_FAILED" + ) + +class BlogContentValidationException(CustomException): + """ + 블로그 콘텐츠 유효성 검사 실패 예외 + @:param field: 유효성 검사 실패한 필드 + @:param reason: 실패 이유 + """ + def __init__(self, field: str, reason: str): + super().__init__( + status_code=400, + detail=f"콘텐츠 유효성 검사 실패: {field} - {reason}", + code="BLOG_CONTENT_VALIDATION_FAILED" + ) + +class BlogElementInteractionException(CustomException): + """ + 블로그 페이지 요소와의 상호작용 실패 예외 + @:param element: 상호작용하려던 요소 + @:param action: 수행하려던 액션 + """ + def __init__(self, element: str, action: str): + super().__init__( + status_code=422, + detail=f"블로그 페이지 요소 상호작용 실패: {element}에서 {action} 작업 실패", + code="BLOG_ELEMENT_INTERACTION_FAILED" + ) + +class BlogServiceUnavailableException(CustomException): + """ + 블로그 서비스 이용 불가 예외 + @:param platform: 이용 불가한 플랫폼 + @:param reason: 이용 불가 이유 + """ + def __init__(self, platform: str, reason: str = "서비스가 일시적으로 이용 불가합니다"): + super().__init__( + status_code=503, + detail=f"{platform} 서비스 이용 불가: {reason}", + code="BLOG_SERVICE_UNAVAILABLE" + ) + +class BlogConfigurationException(CustomException): + """ + 블로그 서비스 설정 오류 예외 + @:param config_item: 설정 오류 항목 + """ + def __init__(self, config_item: str): + super().__init__( + status_code=500, + detail=f"블로그 서비스 설정 오류: {config_item}", + code="BLOG_CONFIGURATION_ERROR" + ) \ No newline at end of file diff --git a/apps/pre-processing-service/app/errors/CrawlingException.py b/apps/pre-processing-service/app/errors/CrawlingException.py new file mode 100644 index 00000000..1928e30f --- /dev/null +++ b/apps/pre-processing-service/app/errors/CrawlingException.py @@ -0,0 +1,63 @@ +from app.errors.CustomException import CustomException +from typing import List + +class PageLoadTimeoutException(CustomException): + """ + 페이지 로드 타임아웃 예외 + @:param url: 로드하려는 페이지의 URL + """ + def __init__(self, url : str): + super().__init__( + status_code=408, + detail=f"페이지 로드가 시간 초과되었습니다. URL: {url}", + code="PAGE_LOAD_TIMEOUT" + ) + +class WebDriverConnectionException(CustomException): + """ + 웹 드라이버 연결 실패 예외 + """ + def __init__(self): + super().__init__( + status_code=500, + detail="웹 드라이버 연결에 실패했습니다.", + code="WEBDRIVER_ERROR" + ) + + +class ElementNotFoundException(CustomException): + """ + 특정 HTML 요소를 찾을 수 없는 예외 + @:param selector: 찾으려는 요소의 CSS 선택자 + """ + def __init__(self, selector: str): + super().__init__( + status_code=404, + detail=f"요소를 찾을 수 없습니다. 선택자: {selector}", + code="ELEMENT_NOT_FOUND" + ) + +class HtmlParsingException(CustomException): + """ + HTML 파싱 실패 예외 + @:param reason: 파싱 실패 이유 + """ + def __init__(self, reason: str): + super().__init__( + status_code=422, + detail=f"HTML 파싱에 실패했습니다. 이유: {reason}", + code="HTML_PARSING_ERROR" + ) + +class DataExtractionException(CustomException): + """ + 데이터 추출 실패 예외 + @:param field: 추출하려는 데이터 필드 목록 + """ + def __init__(self, field: List[str]): + super().__init__( + status_code=422, + detail=f"데이터 추출에 실패했습니다. 필드: {', '.join(field)}", + code="DATA_EXTRACTION_ERROR" + ) + diff --git a/apps/pre-processing-service/app/errors/CustomException.py b/apps/pre-processing-service/app/errors/CustomException.py index c228748e..4c3f84a3 100644 --- a/apps/pre-processing-service/app/errors/CustomException.py +++ b/apps/pre-processing-service/app/errors/CustomException.py @@ -10,6 +10,10 @@ def __init__(self, status_code: int, detail: str, code: str): # 구체적인 커스텀 예외 정의 class ItemNotFoundException(CustomException): + """ + 아이템을 찾을수 없는 예외 + @:param item_id: 찾을수 없는 아이템의 ID + """ def __init__(self, item_id: int): super().__init__( status_code=404, @@ -18,9 +22,23 @@ def __init__(self, item_id: int): ) class InvalidItemDataException(CustomException): + """ + 데이터 유효성 검사 실패 예외 + """ def __init__(self): super().__init__( status_code=422, detail="데이터가 유효하지않습니다..", code="INVALID_ITEM_DATA" + ) + +class DatabaseConnectionException(CustomException): + """ + 데이터베이스 연결 실패 예외 + """ + def __init__(self): + super().__init__( + status_code=500, + detail="데이터베이스 연결에 실패했습니다.", + code="DATABASE_CONNECTION_ERROR" ) \ No newline at end of file diff --git a/apps/pre-processing-service/app/errors/handlers.py b/apps/pre-processing-service/app/errors/handlers.py index db05e176..1b5caf3d 100644 --- a/apps/pre-processing-service/app/errors/handlers.py +++ b/apps/pre-processing-service/app/errors/handlers.py @@ -1,65 +1,90 @@ -# app/errors/handlers.py from fastapi import Request, status from fastapi.responses import JSONResponse +from pydantic import BaseModel from starlette.exceptions import HTTPException as StarletteHTTPException from fastapi.exceptions import RequestValidationError from .messages import ERROR_MESSAGES, get_error_message from ..errors.CustomException import CustomException +class ErrorBaseModel(BaseModel): + """ + 모든 에러 응답의 기반이 되는 Pydantic 모델. + API의 에러 응답 형식을 통일하는 역할을 합니다. + """ + status_code: int + detail: str + code: str + # CustomException 핸들러 async def custom_exception_handler(request: Request, exc: CustomException): """ CustomException을 상속받는 모든 예외를 처리합니다. """ + # 변경점: ErrorBaseModel을 사용하여 응답 본문 생성 + error_content = ErrorBaseModel( + status_code=exc.status_code, + detail=exc.detail, + code=exc.code + ) return JSONResponse( status_code=exc.status_code, - content={ - "error_code": exc.code, - "message": exc.detail, - }, + content=error_content.model_dump(), ) + # FastAPI의 HTTPException 핸들러 (예: 404 Not Found) async def http_exception_handler(request: Request, exc: StarletteHTTPException): """ FastAPI에서 기본적으로 발생하는 HTTP 관련 예외를 처리합니다. """ - if exc.status_code == status.HTTP_404_NOT_FOUND: - # 404 에러의 경우, FastAPI의 기본 "Not Found" 메시지 대신 우리가 정의한 메시지를 사용합니다. - message = ERROR_MESSAGES.get(exc.status_code, "요청하신 리소스를 찾을 수 없습니다.") - else: - # 다른 HTTP 예외들은 FastAPI가 제공하는 detail 메시지를 우선적으로 사용합니다. - message = get_error_message(exc.status_code, exc.detail) + message = get_error_message(exc.status_code, exc.detail) + # 변경점: ErrorBaseModel을 사용하여 응답 본문 생성 + error_content = ErrorBaseModel( + status_code=exc.status_code, + detail=message, + code=f"HTTP_{exc.status_code}" + ) return JSONResponse( status_code=exc.status_code, - content={ - "error_code": f"HTTP_{exc.status_code}", - "message": message - }, + content=error_content.model_dump(), ) + # Pydantic Validation Error 핸들러 (422) async def validation_exception_handler(request: Request, exc: RequestValidationError): """ Pydantic 모델 유효성 검사 실패 시 발생하는 예외를 처리합니다. """ + # 변경점: ErrorBaseModel을 기본 구조로 사용하고, 추가 정보를 더함 + base_error = ErrorBaseModel( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=ERROR_MESSAGES[status.HTTP_422_UNPROCESSABLE_ENTITY], + code="VALIDATION_ERROR" + ) + + # 모델의 내용과 추가적인 'details' 필드를 결합 + response_content = base_error.model_dump() + response_content["details"] = exc.errors() + return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "error_code": "VALIDATION_ERROR", - "message": ERROR_MESSAGES[status.HTTP_422_UNPROCESSABLE_ENTITY], - "details": exc.errors(), - }, + content=response_content, ) + # 처리되지 않은 모든 예외 핸들러 (500) async def unhandled_exception_handler(request: Request, exc: Exception): - # ... + """ + 처리되지 않은 모든 예외를 처리합니다. + """ + # 변경점: ErrorBaseModel을 사용하여 응답 본문 생성 + error_content = ErrorBaseModel( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES[status.HTTP_500_INTERNAL_SERVER_ERROR], + code="INTERNAL_SERVER_ERROR" + ) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "error_code": "INTERNAL_SERVER_ERROR", - "message": ERROR_MESSAGES[status.HTTP_500_INTERNAL_SERVER_ERROR], - }, + content=error_content.model_dump(), ) diff --git a/apps/pre-processing-service/app/main.py b/apps/pre-processing-service/app/main.py index 2ca44875..d13c523d 100644 --- a/apps/pre-processing-service/app/main.py +++ b/apps/pre-processing-service/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from starlette.exceptions import HTTPException as StarletteHTTPException from fastapi.exceptions import RequestValidationError +from app.middleware.ServiceLoggerMiddleware import ServiceLoggerMiddleware # --- 애플리케이션 구성 요소 임포트 --- from app.api.router import api_router @@ -25,6 +26,7 @@ app.add_exception_handler(Exception, unhandled_exception_handler) # --- 미들웨어 등록 --- +app.add_middleware(ServiceLoggerMiddleware) app.add_middleware(LoggingMiddleware) # --- 라우터 등록 --- diff --git a/apps/pre-processing-service/app/middleware/BackServiceLoggerDependency.py b/apps/pre-processing-service/app/middleware/BackServiceLoggerDependency.py new file mode 100644 index 00000000..bbaa2cfd --- /dev/null +++ b/apps/pre-processing-service/app/middleware/BackServiceLoggerDependency.py @@ -0,0 +1,124 @@ +# import time +# from typing import Dict, Any, List, Optional +# from fastapi import Request +# from loguru import logger +# from contextvars import ContextVar +# +# trace_id_context: ContextVar[str] = ContextVar('trace_id', default="NO_TRACE_ID") +# +# +# class ServiceLoggingDependency: +# """ +# 서비스 로깅을 위한 의존성 클래스 +# :param service_type: 서비스 유형 (예: "CHUNKING", "PARSING", "EMBEDDING") +# :param track_params: 추적할 매개변수 이름 목록 +# :param response_trackers: 응답에서 추적할 필드 이름 목록 (딕셔너리) +# """ +# +# def __init__(self, service_type: str, +# track_params: List[str] = None, +# response_trackers: List[str] = None): +# self.service_type = service_type +# self.track_params = track_params or [] +# self.response_trackers = response_trackers or [] +# +# async def __call__(self, request: Request): +# """ +# 의존성 주입 시 호출되는 메서드 +# :param request: FastAPI Request 객체 +# :return: 서비스 유형과 추출된 매개변수 딕셔너리 +# """ +# trace_id = trace_id_context.get("NO_TRACE_ID") +# start_time = time.time() +# +# # 파라미터 추출 +# params = await self._extract_params(request) +# param_str = "" +# if params: +# param_strs = [f"{k}={v}" for k, v in params.items()] +# param_str = " " + " ".join(param_strs) +# +# logger.info(f"[{self.service_type}_START] trace_id={trace_id}{param_str}") +# +# # 응답 시 사용할 정보를 request.state에 저장 +# request.state.service_type = self.service_type +# request.state.start_time = start_time +# request.state.param_str = param_str +# request.state.response_trackers = self.response_trackers +# +# return {"service_type": self.service_type, "params": params} +# +# async def _extract_params(self, request: Request) -> Dict[str, Any]: +# """ +# 요청에서 추적 파라미터 추출 +# :param request: FastAPI Request 객체 +# :return: 추출된 매개변수 딕셔너리 +# """ +# params = {} +# +# try: +# # Query Parameters 추출 +# for key, value in request.query_params.items(): +# if key in self.track_params: +# params[key] = value +# +# # JSON Body 추출 +# try: +# json_body = await request.json() +# if json_body: +# for key, value in json_body.items(): +# if key in self.track_params: +# if isinstance(value, str) and len(value) > 50: +# params[f"{key}_length"] = len(value) +# elif isinstance(value, list): +# params[f"{key}_count"] = len(value) +# else: +# params[key] = value +# except: +# pass +# except: +# pass +# +# return params +# +# # 서비스 응답 시 성공 로그 함수 +# async def log_service_response_with_data(request: Request, response_data: Optional[Dict] = None): +# """ +# 서비스 응답 시 성공 로그 기록 +# :param request: FastAPI Request 객체 +# :param response_data: 응답 데이터 +# """ +# if hasattr(request.state, 'service_type'): +# trace_id = trace_id_context.get("NO_TRACE_ID") +# duration = time.time() - request.state.start_time +# +# # 기본 로그 문자열 +# log_parts = [f"[{request.state.service_type}_SUCCESS]", +# f"trace_id={trace_id}", +# f"execution_time={duration:.4f}s{request.state.param_str}"] +# +# # 응답 데이터에서 추적할 필드 추출 +# if response_data and hasattr(request.state, 'response_trackers'): +# response_params = [] +# for tracker in request.state.response_trackers: +# if tracker in response_data: +# value = response_data[tracker] +# if isinstance(value, dict): +# response_params.append(f"{tracker}_keys={list(value.keys())}") +# response_params.append(f"{tracker}_count={len(value)}") +# elif isinstance(value, list): +# response_params.append(f"{tracker}_count={len(value)}") +# else: +# response_params.append(f"{tracker}={value}") +# +# if response_params: +# log_parts.append(" ".join(response_params)) +# +# logger.info(" ".join(log_parts)) +# return None +# +# naver_search_dependency = ServiceLoggingDependency( +# "NAVER_CRAWLING", +# track_params=["job_id", "schedule_id", "tag", "category", "startDate", "endDate"], +# response_trackers=["keyword", "total_keyword"] +# ) \ No newline at end of file diff --git a/apps/pre-processing-service/app/middleware/ServiceLoggerMiddleware.py b/apps/pre-processing-service/app/middleware/ServiceLoggerMiddleware.py new file mode 100644 index 00000000..edb13f8b --- /dev/null +++ b/apps/pre-processing-service/app/middleware/ServiceLoggerMiddleware.py @@ -0,0 +1,203 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from loguru import logger +from contextvars import ContextVar +from fastapi.responses import JSONResponse +from typing import Dict, Optional, List, Any + +import json +import time + +trace_id_context: ContextVar[str] = ContextVar('trace_id', default="NO_TRACE_ID") + + +class ServiceLoggerMiddleware(BaseHTTPMiddleware): + """ + 완전 자동 서비스 로깅 미들웨어 - 의존성 주입 불필요 + URL 패턴을 기반으로 자동으로 서비스 타입 식별 및 로깅 + """ + + def __init__(self, app, service_mappings: Dict[str, Dict] = None): + """ + :param service_mappings: URL 패턴별 서비스 설정 + 예: { + "/keywords/search": { + "service_type": "NAVER_CRAWLING", + "track_params": ["keyword", "category"], + "response_trackers": ["total_keywords", "results_count"] + } + } + """ + super().__init__(app) + self.service_mappings = service_mappings or self._default_mappings() + + def _default_mappings(self) -> Dict[str, Dict]: + """기본 서비스 매핑 설정""" + return { + "/keywords/search": { + "service_type": "NAVER_CRAWLING", + "track_params": ["keyword", "category", "startDate", "endDate", "job_id", "schedule_id"], + "response_trackers": ["keyword", "total_keywords", "results_count"] + }, + "/blogs/publish": { + "service_type": "BLOG_PUBLISH", + "track_params": ["tag", "title", "content", "tags", "job_id", "schedule_id", "schedule_his_id"], + "response_trackers": ["job_id", "schedule_id", "schedule_his_id", "status", "metadata"] + } + } + + async def dispatch(self, request: Request, call_next): + """요청-응답 사이클을 가로채서 자동 로깅 처리""" + + # 1. 서비스 설정 확인 + service_config = self._get_service_config(request.url.path) + if not service_config: + # 로깅 대상이 아닌 경우 그냥 통과 + return await call_next(request) + + # 2. 시작 로깅 + trace_id = trace_id_context.get("NO_TRACE_ID") + start_time = time.time() + + # 파라미터 추출 및 시작 로그 + params = await self._extract_params(request, service_config["track_params"]) + param_str = "" + if params: + param_strs = [f"{k}={v}" for k, v in params.items()] + param_str = " " + " ".join(param_strs) + + service_type = service_config["service_type"] + logger.info(f"[{service_type}_START] trace_id={trace_id}{param_str}") + + # 3. 요청 처리 + try: + response = await call_next(request) + + # 4. 성공 로깅 + if 200 <= response.status_code < 300: + await self._log_success_response( + service_type, trace_id, start_time, param_str, + response, service_config["response_trackers"] + ) + else: + await self._log_error_response( + service_type, trace_id, start_time, param_str, response + ) + + return response + + except Exception as e: + # 5. 예외 로깅 + await self._log_exception(service_type, trace_id, start_time, param_str, e) + raise + + def _get_service_config(self, url_path: str) -> Optional[Dict]: + """URL 경로를 기반으로 서비스 설정 반환""" + for pattern, config in self.service_mappings.items(): + if self._match_pattern(url_path, pattern): + return config + return None + + def _match_pattern(self, url_path: str, pattern: str) -> bool: + """URL 패턴 매칭 (간단한 구현, 필요시 정규식으로 확장 가능)""" + # 정확히 일치하거나 패턴이 접두사인 경우 + return url_path == pattern or url_path.startswith(pattern.rstrip('*')) + + async def _extract_params(self, request: Request, track_params: List[str]) -> Dict[str, Any]: + """요청에서 추적 파라미터 추출""" + params = {} + + try: + # Query Parameters 추출 + for key, value in request.query_params.items(): + if key in track_params: + params[key] = value + + # JSON Body 추출 + try: + # request body를 읽기 위한 안전한 방법 + body = await request.body() + if body: + json_body = json.loads(body.decode()) + if isinstance(json_body, dict): + for key, value in json_body.items(): + if key in track_params: + if isinstance(value, str) and len(value) > 50: + params[f"{key}_length"] = len(value) + elif isinstance(value, list): + params[f"{key}_count"] = len(value) + else: + params[key] = value + except: + pass + + except Exception as e: + logger.debug(f"파라미터 추출 실패: {e}") + + return params + + async def _log_success_response(self, service_type: str, trace_id: str, + start_time: float, param_str: str, + response: Response, response_trackers: List[str]): + """성공 응답 로깅""" + duration = time.time() - start_time + + log_parts = [ + f"[{service_type}_SUCCESS]", + f"trace_id={trace_id}", + f"execution_time={duration:.4f}s{param_str}", + f"status_code={response.status_code}" + ] + + # 응답 데이터에서 추적 정보 추출 + if isinstance(response, JSONResponse) and response_trackers: + try: + # JSONResponse body 읽기 + if hasattr(response, 'body'): + response_data = json.loads(response.body.decode()) + elif hasattr(response, 'content'): + response_data = response.content + else: + response_data = None + + if response_data and isinstance(response_data, dict): + response_params = [] + for tracker in response_trackers: + if tracker in response_data: + value = response_data[tracker] + if isinstance(value, dict): + response_params.append(f"{tracker}_keys={list(value.keys())}") + response_params.append(f"{tracker}_count={len(value)}") + elif isinstance(value, list): + response_params.append(f"{tracker}_count={len(value)}") + else: + response_params.append(f"{tracker}={value}") + + if response_params: + log_parts.append(" ".join(response_params)) + + except Exception as e: + logger.debug(f"응답 추적 정보 추출 실패: {e}") + + logger.info(" ".join(log_parts)) + + async def _log_error_response(self, service_type: str, trace_id: str, + start_time: float, param_str: str, response: Response): + """에러 응답 로깅""" + duration = time.time() - start_time + logger.error( + f"[{service_type}_ERROR] trace_id={trace_id} " + f"execution_time={duration:.4f}s{param_str} " + f"status_code={response.status_code}" + ) + + async def _log_exception(self, service_type: str, trace_id: str, + start_time: float, param_str: str, exception: Exception): + """예외 로깅""" + duration = time.time() - start_time + logger.error( + f"[{service_type}_EXCEPTION] trace_id={trace_id} " + f"execution_time={duration:.4f}s{param_str} " + f"exception={str(exception)}" + ) \ No newline at end of file diff --git a/apps/pre-processing-service/app/model/schemas.py b/apps/pre-processing-service/app/model/schemas.py index e69de29b..f206f3e9 100644 --- a/apps/pre-processing-service/app/model/schemas.py +++ b/apps/pre-processing-service/app/model/schemas.py @@ -0,0 +1,100 @@ +from datetime import datetime +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field, HttpUrl + + +# 기본 요청 +class RequestBase(BaseModel): + job_id: int + schedule_id: int + schedule_his_id: Optional[int] = None + +# 기본 응답 +class ResponseBase(BaseModel): + job_id: int + schedule_id: int + schedule_his_id: Optional[int] = None + status: str + +# 네이버 키워드 추출 +class RequestNaverSearch(RequestBase): + tag: str + category: Optional[str] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + +class ResponseNaverSearch(ResponseBase): + category: Optional[str] = None + keyword: str + total_keyword: Dict[int, str] + +# 2단계: 검색 +class RequestSadaguSearch(RequestBase): + keyword: str + +class ResponseSadaguSearch(ResponseBase): + keyword: str + search_results: List[Dict] + +# 3단계: 매칭 +class RequestSadaguMatch(RequestBase): + keyword: str + search_results: List[Dict] + +class ResponseSadaguMatch(ResponseBase): + keyword: str + matched_products: List[Dict] + +# 4단계: 유사도 +class RequestSadaguSimilarity(RequestBase): + keyword: str + matched_products: List[Dict] + search_results: Optional[List[Dict]] = None # 3단계에서 매칭 실패시 폴백용 + +class ResponseSadaguSimilarity(ResponseBase): + keyword: str + selected_product: Optional[Dict] = None + reason: Optional[str] = None + +# 사다구몰 크롤링 +class RequestSadaguCrawl(BaseModel): + job_id: int = Field(..., description="작업 ID") + schedule_id: int = Field(..., description="스케줄 ID") + schedule_his_id: int = Field(..., description="스케줄 히스토리 ID") + tag: str = Field(..., description="크롤링 태그 (예: 'detail')") + product_url: HttpUrl = Field(..., description="크롤링할 상품의 URL") + use_selenium: bool = Field(default=True, description="Selenium 사용 여부") + include_images: bool = Field(default=False, description="이미지 정보 포함 여부") + +class ResponseSadaguCrawl(BaseModel): + job_id: int + schedule_id: int + schedule_his_id: int + tag: str + product_url: str + use_selenium: bool + include_images: bool + product_detail: Optional[Dict] = None + status: str + crawled_at: Optional[str] = None + +# 블로그 생성 +class RequestBlogCreate(RequestBase): + tag: str + category: str + +class ResponseBlogCreate(ResponseBase): + pass + +# 블로그 배포 +class RequestBlogPublish(RequestBase): + tag: str + category: str + + # 임의로 추가 + title: str + content: str + tags: List[str] + +class ResponseBlogPublish(ResponseBase): + metadata: Optional[Dict[str, Any]] \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/__init__.py b/apps/pre-processing-service/app/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/service/blog/__init__.py b/apps/pre-processing-service/app/service/blog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/service/blog/base_blog_post_service.py b/apps/pre-processing-service/app/service/blog/base_blog_post_service.py new file mode 100644 index 00000000..55aa34e9 --- /dev/null +++ b/apps/pre-processing-service/app/service/blog/base_blog_post_service.py @@ -0,0 +1,94 @@ +from abc import ABC, abstractmethod +from typing import Dict + +from app.utils.crawling_util import CrawlingUtil +from app.errors.BlogPostingException import * +from app.errors.CrawlingException import * + +class BaseBlogPostService(ABC): + """ + 블로그 포스팅 서비스 추상 클래스 + """ + + def __init__(self): + """공통 초기화 로직""" + try: + self.crawling_service = CrawlingUtil() + self.web_driver = self.crawling_service.get_driver() + self.wait_driver = self.crawling_service.get_wait() + except Exception: + raise WebDriverConnectionException() + + self._load_config() + + @abstractmethod + def _load_config(self) -> None: + """플랫폼별 설정 로드""" + pass + + @abstractmethod + def _login(self) -> None: + """플랫폼별 로그인 구현""" + pass + + @abstractmethod + def _write_content(self, title: str, content: str, tags: List[str] = None) -> None: + """ + 플랫폼별 포스팅 작성 구현 + :param title: 포스트 제목 + :param content: 포스트 내용 + :param tags: 포스트 태그 리스트 + """ + pass + + @abstractmethod + def _get_platform_name(self) -> str: + """플랫폼 이름 반환""" + pass + + @abstractmethod + def _validate_content(self, title: str, content: str, tags: Optional[List[str]] = None) -> None: + """ + 공통 유효성 검사 로직 + :param title: 포스트 제목 + :param content: 포스트 내용 + :param tags: 포스트 태그 리스트 + """ + # if not title or not title.strip(): + # raise BlogContentValidationException("title", "제목이 비어있습니다") + # + # if not content or not content.strip(): + # raise BlogContentValidationException("content", "내용이 비어있습니다") + # + # if tags is None: + # raise BlogContentValidationException("tags", "태그가 비어있습니다") + + def post_content(self, title: str, content: str, tags: List[str] = None) -> Dict: + """ + 블로그 포스팅 통합 메서드 + :param title: 포스트 제목 + :param content: 포스트 내용 + :param tags: 포스트 태그 리스트 + :return: 포스팅 결과 요약 딕셔너리 + """ + # 1. 콘텐츠 유효성 검사 + self._validate_content(title, content, tags) + + # 2. 로그인 + self._login() + + # 3. 포스트 작성 및 발행 + self._write_content(title, content, tags) + + # 4. 결과 반환 + return { + "platform": self._get_platform_name(), + "title": title, + "content_length": len(content), + "tags": tags or [] + } + + def __del__(self): + """공통 리소스 정리""" + if hasattr(self, 'web_driver') and self.web_driver: + self.web_driver.quit() \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/blog/naver_blog_post_service.py b/apps/pre-processing-service/app/service/blog/naver_blog_post_service.py new file mode 100644 index 00000000..0aaf9431 --- /dev/null +++ b/apps/pre-processing-service/app/service/blog/naver_blog_post_service.py @@ -0,0 +1,194 @@ +import os +import time +import pyperclip + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.keys import Keys +from selenium.common.exceptions import TimeoutException + +from app.errors.CrawlingException import * +from app.errors.BlogPostingException import * +from app.service.blog.base_blog_post_service import BaseBlogPostService + +class NaverBlogPostService(BaseBlogPostService): + """네이버 블로그 포스팅 서비스 구현""" + + def _load_config(self) -> None: + """네이버 블로그 설정 로드""" + + self.id = os.getenv("NAVER_ID", "all2641") + self.password = os.getenv("NAVER_PASSWORD", "cjh83520*") + self.login_url = "https://nid.naver.com/nidlogin.login" + self.post_content_url = f"https://blog.naver.com/PostWriteForm.naver?blogId={self.id}&Redirect=Write&redirect=Write&widgetTypeCall=true&noTrackingCode=true&directAccess=false" + + def _get_platform_name(self) -> str: + return "NAVER_BLOG" + + def _validate_content(self, title: str, content: str, tags: Optional[List[str]] = None) -> None: + """공통 유효성 검사 로직""" + + if not title or not title.strip(): + raise BlogContentValidationException("title", "제목이 비어있습니다") + + if not content or not content.strip(): + raise BlogContentValidationException("content", "내용이 비어있습니다") + + if tags is None: + raise BlogContentValidationException("tags", "태그가 비어있습니다") + + def _login(self) -> None: + """네이버 로그인 구현""" + + try: + self.web_driver.get(self.login_url) + + try: + id_input = self.wait_driver.until( + EC.presence_of_element_located((By.ID, "id")) + ) + time.sleep(2) + except TimeoutException: + raise ElementNotFoundException("id") + + pyperclip.copy(self.id) + time.sleep(1) + id_input.send_keys(Keys.COMMAND, 'v') + time.sleep(1) + + # 비밀번호 입력 + try: + password_input = self.wait_driver.until( + EC.presence_of_element_located((By.ID, "pw")) + ) + except TimeoutException: + raise ElementNotFoundException("pw") + + pyperclip.copy(self.password) + time.sleep(1) + password_input.send_keys(Keys.COMMAND, 'v') + time.sleep(1) + + # 로그인 버튼 클릭 + try: + login_button = self.wait_driver.until( + EC.element_to_be_clickable((By.ID, "log.login")) + ) + login_button.click() + time.sleep(3) + except TimeoutException: + raise ElementNotFoundException("log.login") + + except (ElementNotFoundException, BlogLoginException): + raise + except TimeoutException: + raise PageLoadTimeoutException(self.login_url) + except WebDriverConnectionException: + raise BlogServiceUnavailableException("네이버 블로그", "네트워크 연결 오류 또는 페이지 로드 실패") + except Exception as e: + raise BlogLoginException("네이버 블로그", f"예상치 못한 오류: {str(e)}") + + def _write_content(self, title: str, content: str, tags: List[str] = None) -> None: + """네이버 블로그 포스팅 작성 구현""" + from selenium.webdriver.common.by import By + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.common.action_chains import ActionChains + from selenium.common.exceptions import TimeoutException + + try: + self.web_driver.get(self.post_content_url) + + # 기존 작성 글 팝업 닫기 (있을 경우) + try: + cancel = self.wait_driver.until( + EC.element_to_be_clickable((By.CSS_SELECTOR, '.se-popup-button.se-popup-button-cancel')) + ) + cancel.click() + time.sleep(1) + except: + pass + + # 제목 입력 + try: + title_element = self.wait_driver.until( + EC.element_to_be_clickable((By.CSS_SELECTOR, '.se-placeholder.__se_placeholder.se-fs32')) + ) + ActionChains(self.web_driver).move_to_element(title_element).click().pause(0.2).send_keys( + title).perform() + time.sleep(1) + except TimeoutException: + raise BlogElementInteractionException("제목 입력 필드", "제목 입력") + + # 본문 입력 + try: + body_element = self.wait_driver.until( + EC.element_to_be_clickable((By.CSS_SELECTOR, '.se-component.se-text.se-l-default')) + ) + ActionChains(self.web_driver).move_to_element(body_element).click().pause(0.2) \ + .send_keys(content).pause(0.2).send_keys(Keys.ENTER).perform() + time.sleep(1) + except TimeoutException: + raise BlogElementInteractionException("본문 입력 필드", "본문 입력") + + # 발행 버튼 클릭 + try: + publish_btn = self.wait_driver.until( + EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='발행']]")) + ) + try: + publish_btn.click() + except Exception: + self.web_driver.execute_script("arguments[0].click();", publish_btn) + time.sleep(2) + except TimeoutException: + raise BlogElementInteractionException("발행 버튼", "버튼 클릭") + + # 태그 입력 + if tags: + try: + tag_input = self.wait_driver.until( + EC.element_to_be_clickable((By.CSS_SELECTOR, "input[placeholder*='태그']")) + ) + for tag in tags: + tag_input.send_keys(tag) + tag_input.send_keys(Keys.SPACE) + time.sleep(0.5) + except TimeoutException: + raise BlogElementInteractionException("태그 입력 필드", "태그 입력") + + # 최종 발행 확인 + try: + time.sleep(1) + final_btn = self.wait_driver.until( + EC.element_to_be_clickable((By.XPATH, + "//div[contains(@class,'layer') or contains(@class,'popup') or @role='dialog']//*[self::button or self::a][.//span[normalize-space()='발행']]")) + ) + try: + final_btn.click() + except Exception: + self.web_driver.execute_script("arguments[0].click();", final_btn) + except TimeoutException: + raise BlogElementInteractionException("최종 발행 버튼", "버튼 클릭") + + # 발행 완료 확인 + try: + self.wait_driver.until( + EC.any_of( + EC.url_contains("PostView.naver"), + EC.url_contains("postList"), + EC.url_contains("postList.naver"), + EC.url_contains("entry.naver") + ) + ) + except TimeoutException: + pass + + except (BlogElementInteractionException, BlogPostPublishException): + raise + except TimeoutException: + raise PageLoadTimeoutException(self.post_content_url) + except WebDriverConnectionException: + raise BlogServiceUnavailableException("네이버 블로그", "페이지 로드 중 네트워크 오류") + except Exception as e: + raise BlogPostPublishException("네이버 블로그", f"예상치 못한 오류: {str(e)}") diff --git a/apps/pre-processing-service/app/service/blog/tistory_blog_post_service.py b/apps/pre-processing-service/app/service/blog/tistory_blog_post_service.py new file mode 100644 index 00000000..bcb2abaf --- /dev/null +++ b/apps/pre-processing-service/app/service/blog/tistory_blog_post_service.py @@ -0,0 +1,218 @@ +import os +import time + +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import TimeoutException + +from app.errors.CrawlingException import * +from app.errors.BlogPostingException import * +from app.service.blog.base_blog_post_service import BaseBlogPostService + +class TistoryBlogPostService(BaseBlogPostService): + """티스토리 블로그 포스팅 서비스""" + + def _load_config(self) -> None: + """티스토리 블로그 설정 로드""" + + self.blog_name = os.getenv("TISTORY_BLOG_NAME", "hoons2641") + self.id = os.getenv("TISTORY_ID", "fair_05@nate.com") + self.password = os.getenv("TISTORY_PASSWORD", "kdyn264105*") + self.login_url = "https://accounts.kakao.com/login/?continue=https%3A%2F%2Fkauth.kakao.com%2Foauth%2Fauthorize%3Fclient_id%3D3e6ddd834b023f24221217e370daed18%26state%3DaHR0cHM6Ly93d3cudGlzdG9yeS5jb20v%26redirect_uri%3Dhttps%253A%252F%252Fwww.tistory.com%252Fauth%252Fkakao%252Fredirect%26response_type%3Dcode%26auth_tran_id%3Dslj3F.mFC~2JNOiCOGi5HdGPKOA.Pce4l5tiS~3fZkInLGuEG3tMq~xZkxx4%26ka%3Dsdk%252F2.7.3%2520os%252Fjavascript%2520sdk_type%252Fjavascript%2520lang%252Fko-KR%2520device%252FMacIntel%2520origin%252Fhttps%25253A%25252F%25252Fwww.tistory.com%26is_popup%3Dfalse%26through_account%3Dtrue&talk_login=hidden#login" + self.post_content_url = f"https://{self.blog_name}.tistory.com/manage/newpost" + + def _get_platform_name(self) -> str: + return "TISTORY_BLOG" + + def _validate_content(self, title: str, content: str, tags: Optional[List[str]] = None) -> None: + """공통 유효성 검사 로직""" + + if not title or not title.strip(): + raise BlogContentValidationException("title", "제목이 비어있습니다") + + if not content or not content.strip(): + raise BlogContentValidationException("content", "내용이 비어있습니다") + + if tags is None: + raise BlogContentValidationException("tags", "태그가 비어있습니다") + + def _login(self) -> None: + """티스토리 로그인 구현""" + + try: + self.web_driver.get(self.login_url) + + # 아이디 입력 + try: + id_input = self.wait_driver.until( + EC.presence_of_element_located((By.ID, "loginId--1")) + ) + except TimeoutException: + raise ElementNotFoundException("loginId--1") + + id_input.clear() + id_input.send_keys(self.id) + time.sleep(1) + + # 비밀번호 입력 + try: + password_input = self.wait_driver.until( + EC.presence_of_element_located((By.ID, "password--2")) + ) + except TimeoutException: + raise ElementNotFoundException("password--2") + + password_input.clear() + password_input.send_keys(self.password) + time.sleep(1) + + # 로그인 버튼 클릭 + try: + login_button = self.wait_driver.until( + EC.element_to_be_clickable((By.XPATH, "//*[text()='로그인']")) + ) + login_button.click() + time.sleep(2) + except TimeoutException: + raise ElementNotFoundException("로그인 버튼") + + except (ElementNotFoundException, BlogLoginException): + raise + except TimeoutException: + raise PageLoadTimeoutException(self.login_url) + except WebDriverConnectionException: + raise BlogServiceUnavailableException("티스토리 블로그", "네트워크 연결 오류 또는 페이지 로드 실패") + except Exception as e: + raise BlogLoginException("티스토리 블로그", f"예상치 못한 오류: {str(e)}") + + def _write_content(self, title: str, content: str, tags: List[str] = None) -> None: + """티스토리 블로그 포스팅 작성 구현""" + + try: + self.web_driver.get(self.post_content_url) + time.sleep(3) + + # 제목 입력 + try: + title_input = self.wait_driver.until( + EC.presence_of_element_located((By.TAG_NAME, "textarea")) + ) + title_input.clear() + title_input.send_keys(title) + time.sleep(1) + except TimeoutException: + raise BlogElementInteractionException("제목 입력 필드", "제목 입력") + + # 내용 입력 + try: + iframe = self.wait_driver.until( + EC.presence_of_element_located( + (By.XPATH, "//iframe[contains(@title, 'Rich Text Area') or contains(@id, 'editor')]")) + ) + self.web_driver.switch_to.frame(iframe) + + body = self.wait_driver.until( + EC.presence_of_element_located((By.TAG_NAME, "body")) + ) + body.clear() + body.send_keys(content) + + self.web_driver.switch_to.default_content() + + except Exception: + try: + # 일반 textarea나 div 에디터 찾기 + content_selectors = [ + "//div[@contenteditable='true']", + "//textarea[contains(@class, 'editor')]", + "//div[contains(@class, 'editor')]" + ] + + content_area = None + for selector in content_selectors: + try: + content_area = self.web_driver.find_element(By.XPATH, selector) + break + except: + continue + + if content_area: + content_area.clear() + content_area.send_keys(content) + else: + raise BlogElementInteractionException("본문 입력 필드", "본문 입력") + + except Exception: + raise BlogElementInteractionException("본문 입력 필드", "본문 입력") + + # 태그 입력 + if tags and len(tags) > 0: + try: + tag_input = self.wait_driver.until( + EC.presence_of_element_located( + (By.XPATH, "//input[@placeholder='태그입력' or contains(@placeholder, '태그')]")) + ) + tag_input.clear() + + for i, tag in enumerate(tags): + tag_input.send_keys(tag) + if i < len(tags) - 1: + tag_input.send_keys(",") + time.sleep(0.5) + + except Exception: + raise BlogElementInteractionException("태그 입력 필드", "태그 입력") + + # 완료 버튼 클릭 + try: + complete_button = self.wait_driver.until( + EC.element_to_be_clickable((By.XPATH, "//*[text()='완료']")) + ) + complete_button.click() + time.sleep(3) + except TimeoutException: + raise BlogElementInteractionException("완료 버튼", "버튼 클릭") + + # 발행 설정 + try: + public_option = self.wait_driver.until( + EC.element_to_be_clickable((By.XPATH, "//*[text()='공개']")) + ) + public_option.click() + time.sleep(1) + + publish_button = self.wait_driver.until( + EC.element_to_be_clickable((By.XPATH, "//*[text()='공개 발행']")) + ) + publish_button.click() + time.sleep(3) + + except Exception: + try: + publish_selectors = [ + "//button[contains(text(), '발행')]", + "//button[contains(text(), '저장')]", + "//*[@class='btn_publish' or contains(@class, 'publish')]" + ] + + for selector in publish_selectors: + try: + publish_btn = self.web_driver.find_element(By.XPATH, selector) + publish_btn.click() + break + except: + continue + else: + raise BlogPostPublishException("티스토리 블로그", "발행 버튼을 찾을 수 없습니다") + + except Exception: + raise BlogPostPublishException("티스토리 블로그", "발행 과정에서 오류가 발생했습니다") + + except (BlogElementInteractionException, BlogPostPublishException): + raise + except TimeoutException: + raise PageLoadTimeoutException(self.post_content_url) + except WebDriverConnectionException: + raise BlogServiceUnavailableException("티스토리 블로그", "페이지 로드 중 네트워크 오류") + except Exception as e: + raise BlogPostPublishException("티스토리 블로그", f"예상치 못한 오류: {str(e)}") diff --git a/apps/pre-processing-service/app/service/crawl_service.py b/apps/pre-processing-service/app/service/crawl_service.py new file mode 100644 index 00000000..11844ead --- /dev/null +++ b/apps/pre-processing-service/app/service/crawl_service.py @@ -0,0 +1,49 @@ +# app/service/crawl_service.py +import time +from app.utils.crawler_utils import DetailCrawler +from app.errors.CustomException import InvalidItemDataException +from app.model.schemas import RequestSadaguCrawl + + +async def crawl_product_detail(request: RequestSadaguCrawl) -> dict: + """ + 선택된 상품의 상세 정보를 크롤링하는 비즈니스 로직입니다. (5단계) + 상품 URL을 입력받아 상세 정보를 크롤링하여 딕셔너리로 반환합니다. + """ + crawler = DetailCrawler(use_selenium=request.use_selenium) + + try: + print(f"상품 상세 크롤링 시작: {request.product_url}") + + # 상세 정보 크롤링 실행 + product_detail = await crawler.crawl_detail( + product_url=str(request.product_url), + include_images=request.include_images + ) + + if not product_detail: + raise InvalidItemDataException("상품 상세 정보 크롤링 실패") + + print(f"크롤링 완료: {product_detail.get('title', 'Unknown')[:50]}") + + # 응답 데이터 구성 + response_data = { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "tag": request.tag, + "product_url": str(request.product_url), + "use_selenium": request.use_selenium, + "include_images": request.include_images, + "product_detail": product_detail, + "status": "success", + "crawled_at": time.strftime('%Y-%m-%d %H:%M:%S') + } + + return response_data + + except Exception as e: + print(f"크롤링 서비스 오류: {e}") + raise InvalidItemDataException(f"상품 상세 크롤링 오류: {e}") + finally: + await crawler.close() \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/keyword_service.py b/apps/pre-processing-service/app/service/keyword_service.py new file mode 100644 index 00000000..da39aac9 --- /dev/null +++ b/apps/pre-processing-service/app/service/keyword_service.py @@ -0,0 +1,92 @@ +# Pydantic 모델을 가져오기 위해 schemas 파일 import +import json +import random + +import httpx +from starlette import status + +from ..errors.CustomException import InvalidItemDataException +from ..model.schemas import RequestNaverSearch + +async def keyword_search(request: RequestNaverSearch) -> dict: + """ + 네이버 검색 요청을 처리하는 비즈니스 로직입니다. + 입력받은 데이터를 기반으로 응답 데이터를 생성하여 딕셔너리로 반환합니다. + """ + + #키워드 검색 + if request.tag == "naver": + trending_keywords = await search_naver_rank(**request.model_dump(include={'category', 'start_date', 'end_date'})) + elif request.tag == "naver_store": + trending_keywords = await search_naver_store() + else : + raise InvalidItemDataException() + + if not trending_keywords: + raise InvalidItemDataException() + + response_data = request.model_dump() + response_data["keyword"] = random.choice(list(trending_keywords.values())) + response_data["total_keyword"] = trending_keywords + response_data["status"] = "success" + return response_data + +async def search_naver_rank(category,start_date,end_date) -> dict[int,str]: + """ + 네이버 데이터 랩 키워드 검색 모듈 + """ + url = "https://datalab.naver.com/shoppingInsight/getCategoryKeywordRank.naver" + headers = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Referer": "https://datalab.naver.com/shoppingInsight/sCategory.naver", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" + } + keywords_dic ={} + async with httpx.AsyncClient() as client: + for page in range(1, 3): + payload = { + "cid": category, + "timeUnit": "date", + "startDate": start_date, + "endDate": end_date, + "age": "", + "gender": "", + "device": "", + "page": page, + } + try: + response = await client.post(url, headers=headers, data=payload) + response.raise_for_status() + data = response.json() + for item in data.get('ranks', []): + keywords_dic[item.get('rank')] = item.get('keyword') + except (httpx.HTTPStatusError, httpx.RequestError, json.JSONDecodeError) as e: + print(f"네이버 데이터랩에서 데이터를 가져오는 데 실패했습니다: {e}") + raise InvalidItemDataException + return keywords_dic + + +async def search_naver_store() -> dict[int,str]: + """ + 네이버 스토어의 일일 인기 검색어 순위 데이터를 가져옵니다. + API 응답의 'keyword' 필드를 'title'로 변경하여 전체 순위 목록을 반환합니다. + """ + url = "https://snxbest.naver.com/api/v1/snxbest/keyword/rank?ageType=ALL&categoryId=A&sortType=KEYWORD_POPULAR&periodType=DAILY" + headers = {} + + async with httpx.AsyncClient() as client: + try: + # API에 GET 요청을 보냅니다. + response = await client.get(url, headers=headers) + response.raise_for_status() # HTTP 오류 발생 시 예외를 일으킵니다. + data = response.json() + keyword_dict = {} + + for item in data: + keyword_dict[item['rank']] = item['title'] + + return keyword_dict + + except (httpx.HTTPStatusError, httpx.RequestError, json.JSONDecodeError) as e: + print(f"네이버 스토어에서 데이터를 가져오는 데 실패했습니다: {e}") + raise InvalidItemDataException from e \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/match_service.py b/apps/pre-processing-service/app/service/match_service.py new file mode 100644 index 00000000..6b1cc171 --- /dev/null +++ b/apps/pre-processing-service/app/service/match_service.py @@ -0,0 +1,66 @@ +from app.utils.keyword_matcher import KeywordMatcher +from app.errors.CustomException import InvalidItemDataException +from ..model.schemas import RequestSadaguMatch + + +def match_products(request: RequestSadaguMatch) -> dict: + """ + 키워드 매칭 로직 (MeCab 등 사용) - 3단계 + """ + keyword = request.keyword + products = request.search_results + + if not products: + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "matched_products": [], + "status": "success" + } + + try: + matcher = KeywordMatcher() + matched_products = [] + + print(f"키워드 '{keyword}'와 {len(products)}개 상품 매칭 분석 시작...") + + for i, product in enumerate(products): + title = product.get('title', '') + if not title: + continue + + # 키워드 매칭 분석 + match_result = matcher.analyze_keyword_match(title, keyword) + + print(f"상품 {i + 1}: {title[:50]} | {match_result['reason']}") + + if match_result['is_match']: + # 매칭된 상품에 매칭 정보 추가 + matched_product = product.copy() + matched_product['match_info'] = { + 'match_type': match_result['match_type'], + 'match_score': match_result['score'], + 'match_reason': match_result['reason'] + } + matched_products.append(matched_product) + print(f" ✅ 매칭됨!") + + print(f"매칭 결과: {len(matched_products)}개 상품") + + # 매칭 스코어 기준으로 정렬 (높은 순) + matched_products.sort(key=lambda x: x['match_info']['match_score'], reverse=True) + + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "matched_products": matched_products, + "status": "success" + } + + except Exception as e: + print(f"매칭 서비스 오류: {e}") + raise InvalidItemDataException(f"키워드 매칭 실패: {str(e)}") \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/search_service.py b/apps/pre-processing-service/app/service/search_service.py new file mode 100644 index 00000000..da7aa1fd --- /dev/null +++ b/apps/pre-processing-service/app/service/search_service.py @@ -0,0 +1,81 @@ +from app.utils.crawler_utils import SearchCrawler +from app.errors.CustomException import InvalidItemDataException +from ..model.schemas import RequestSadaguSearch + + +async def search_products(request: RequestSadaguSearch) -> dict: + """ + 키워드 기반으로 상품을 검색하는 비즈니스 로직 (2단계) + """ + keyword = request.keyword + crawler = SearchCrawler(use_selenium=True) + + try: + print(f"키워드 '{keyword}'로 상품 검색 시작...") + + # Selenium 또는 httpx로 상품 검색 + if crawler.use_selenium: + search_results = await crawler.search_products_selenium(keyword) + else: + search_results = await crawler.search_products_httpx(keyword) + + if not search_results: + print("검색 결과가 없습니다.") + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "search_results": [], + "status": "success" + } + + # 상품별 기본 정보 수집 (제목이 없는 경우 다시 크롤링) + enriched_results = [] + print(f"총 {len(search_results)}개 상품의 기본 정보를 수집 중...") + + for i, product in enumerate(search_results): + try: + # 이미 제목이 있고 유효한 경우 그대로 사용 + if product.get('title') and product['title'] != 'Unknown Title' and len(product['title'].strip()) > 0: + enriched_results.append(product) + else: + # 제목이 없거나 유효하지 않은 경우 다시 크롤링 + print(f"상품 {i + 1}: 제목 재수집 중... ({product['url']})") + basic_info = await crawler.get_basic_product_info(product['url']) + + if basic_info and basic_info['title'] != "제목 없음": + enriched_results.append({ + 'url': product['url'], + 'title': basic_info['title'] + }) + else: + # 그래도 제목을 못 찾으면 제외 + print(f" 제목 추출 실패, 제외") + continue + + # 최대 20개까지만 처리 + if len(enriched_results) >= 20: + break + + except Exception as e: + print(f"상품 {i + 1} 처리 중 오류: {e}") + continue + + print(f"최종 수집된 유효 상품: {len(enriched_results)}개") + + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "search_results": enriched_results, + "status": "success" + } + + except Exception as e: + print(f"검색 서비스 오류: {e}") + raise InvalidItemDataException(f"상품 검색 실패: {str(e)}") + + finally: + await crawler.close() \ No newline at end of file diff --git a/apps/pre-processing-service/app/service/similarity_service.py b/apps/pre-processing-service/app/service/similarity_service.py new file mode 100644 index 00000000..27823e9e --- /dev/null +++ b/apps/pre-processing-service/app/service/similarity_service.py @@ -0,0 +1,137 @@ +from app.utils.similarity_analyzer import SimilarityAnalyzer +from app.errors.CustomException import InvalidItemDataException +from ..model.schemas import RequestSadaguSimilarity + + +def select_product_by_similarity(request: RequestSadaguSimilarity) -> dict: + """ + BERT 기반 유사도 분석 후 상품 선택 - 4단계 + """ + keyword = request.keyword + candidates = request.matched_products + fallback_products = request.search_results or [] + + # 매칭된 상품이 없으면 전체 검색 결과로 폴백 + if not candidates: + if not fallback_products: + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "selected_product": None, + "reason": "매칭된 상품과 검색 결과가 모두 없음", + "status": "success" + } + + print("매칭된 상품 없음 → 전체 검색 결과에서 유사도 분석") + candidates = fallback_products + analysis_mode = "fallback_similarity_only" + else: + analysis_mode = "matched_products" + + try: + analyzer = SimilarityAnalyzer() + + print(f"키워드 '{keyword}'와 {len(candidates)}개 상품의 유사도 분석 시작... (모드: {analysis_mode})") + + # 한 개만 있으면 바로 선택 + if len(candidates) == 1: + selected_product = candidates[0] + + # 유사도 계산 + similarity = analyzer.calculate_similarity(keyword, selected_product['title']) + + # 폴백 모드에서는 임계값 검증 + if analysis_mode == "fallback_similarity_only": + similarity_threshold = 0.3 + if similarity < similarity_threshold: + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "selected_product": None, + "reason": f"단일 상품 유사도({similarity:.4f}) < 기준({similarity_threshold})", + "status": "success" + } + + selected_product['similarity_info'] = { + 'similarity_score': float(similarity), + 'analysis_type': 'single_candidate', + 'analysis_mode': analysis_mode + } + + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "selected_product": selected_product, + "reason": f"단일 상품 - 유사도: {similarity:.4f} ({analysis_mode})", + "status": "success" + } + + # 여러 개가 있으면 유사도 비교 + print("여러 상품 중 최고 유사도로 선택...") + + # 제목만 추출해서 배치 분석 + titles = [product['title'] for product in candidates] + similarity_results = analyzer.analyze_similarity_batch(keyword, titles) + + # 결과 출력 + for result in similarity_results: + print(f" {result['title'][:40]} | 유사도: {result['similarity']:.4f}") + + # 최고 유사도 선택 + best_result = similarity_results[0] + selected_product = candidates[best_result['index']].copy() + + # 폴백 모드에서는 임계값 검증 + similarity_threshold = 0.3 + if analysis_mode == "fallback_similarity_only" and best_result['similarity'] < similarity_threshold: + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "selected_product": None, + "reason": f"최고 유사도({best_result['similarity']:.4f}) < 기준({similarity_threshold})", + "status": "success" + } + + # 유사도 정보 추가 + selected_product['similarity_info'] = { + 'similarity_score': best_result['similarity'], + 'analysis_type': 'multi_candidate_bert', + 'analysis_mode': analysis_mode, + 'rank': 1, + 'total_candidates': len(candidates) + } + + # 매칭 모드에서는 종합 점수도 계산 + if analysis_mode == "matched_products" and 'match_info' in selected_product: + match_score = selected_product['match_info']['match_score'] + similarity_score = best_result['similarity'] + # 가중치: 매칭 40%, 유사도 60% + final_score = match_score * 0.4 + similarity_score * 0.6 + selected_product['final_score'] = final_score + reason = f"종합점수({final_score:.4f}) = 매칭({match_score:.4f})*0.4 + 유사도({similarity_score:.4f})*0.6" + else: + reason = f"유사도({best_result['similarity']:.4f}) 기준 선택 ({analysis_mode})" + + print(f"선택됨: {selected_product['title'][:50]} | {reason}") + + return { + "job_id": request.job_id, + "schedule_id": request.schedule_id, + "schedule_his_id": request.schedule_his_id, + "keyword": keyword, + "selected_product": selected_product, + "reason": reason, + "status": "success" + } + + except Exception as e: + print(f"유사도 분석 서비스 오류: {e}") + raise InvalidItemDataException(f"유사도 분석 실패: {str(e)}") \ No newline at end of file diff --git a/apps/pre-processing-service/app/test/__init__.py b/apps/pre-processing-service/app/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/test/test_keyword.py b/apps/pre-processing-service/app/test/test_keyword.py new file mode 100644 index 00000000..e0432139 --- /dev/null +++ b/apps/pre-processing-service/app/test/test_keyword.py @@ -0,0 +1,44 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +JOB_ID = 1 +SCHEDULE_ID = 1 +SCHEDULE_HIS_ID = 1 + + +def test_read_root(): + response = client.get("/keyword/") + assert response.status_code == 200 + assert response.json() == {"message": "keyword API"} + + +@pytest.mark.parametrize("tag, category, start_date, end_date", [ + ("naver", "50000000", "2025-09-01", "2025-09-02"), + ("naver", "50000001", "2025-09-01", "2025-09-02"), + ("naver", "50000002", "2025-09-01", "2025-09-02"), + ("naver_store", "", "2025-09-01", "2025-09-02"), +]) +def test_search(tag, category, start_date, end_date): + body = { + "job_id": JOB_ID, + "schedule_id": SCHEDULE_ID, + "schedule_his_id": SCHEDULE_HIS_ID, # 오타 수정 + "tag": tag, + "category": category, + "start_date": start_date, + "end_date": end_date + } + + response = client.post("/keyword/search", json=body) + assert response.status_code == 200 + + response_data = response.json() + assert response_data["job_id"] == body["job_id"] + assert response_data["schedule_id"] == body["schedule_id"] + assert response_data["schedule_his_id"] == body["schedule_his_id"] # 오타 수정 + assert response_data["status"] == "success" + assert "keyword" in response_data + assert isinstance(response_data["total_keyword"], dict) \ No newline at end of file diff --git a/apps/pre-processing-service/app/test/test_match_service.py b/apps/pre-processing-service/app/test/test_match_service.py new file mode 100644 index 00000000..7b80c258 --- /dev/null +++ b/apps/pre-processing-service/app/test/test_match_service.py @@ -0,0 +1,97 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_match_success(): + """키워드 매칭 성공 테스트""" + sample_search_results = [ + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", + "title": "925 실버 반지 여성용 결혼반지" + }, + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=456", + "title": "골드 목걸이 체인 펜던트" + }, + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=789", + "title": "반지 세트 커플링 약혼반지" + } + ] + + body = { + "job_id": 1, + "schedule_id": 1, + "schedule_his_id": 1, + "keyword": "반지", + "search_results": sample_search_results + } + + response = client.post("/product/match", json=body) + print(f"Match Response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == body["job_id"] + assert data["keyword"] == body["keyword"] + assert data["status"] == "success" + assert isinstance(data["matched_products"], list) + + # 반지가 포함된 상품들이 매칭되어야 함 + if data["matched_products"]: + for product in data["matched_products"]: + assert "match_info" in product + assert "match_type" in product["match_info"] + assert "match_score" in product["match_info"] + + +def test_match_no_results(): + """검색 결과가 없는 경우""" + body = { + "job_id": 2, + "schedule_id": 2, + "schedule_his_id": 2, + "keyword": "반지", + "search_results": [] + } + + response = client.post("/product/match", json=body) + print(f"No results response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["matched_products"] == [] + + +def test_match_no_matches(): + """키워드와 매칭되지 않는 상품들""" + sample_search_results = [ + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", + "title": "컴퓨터 키보드 게이밍" + }, + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=456", + "title": "스마트폰 케이스 투명" + } + ] + + body = { + "job_id": 3, + "schedule_id": 3, + "schedule_his_id": 3, + "keyword": "반지", + "search_results": sample_search_results + } + + response = client.post("/product/match", json=body) + print(f"No matches response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + # 매칭되지 않아도 성공으로 처리 + assert data["status"] == "success" + assert isinstance(data["matched_products"], list) \ No newline at end of file diff --git a/apps/pre-processing-service/app/test/test_sadagu_crawl.py b/apps/pre-processing-service/app/test/test_sadagu_crawl.py new file mode 100644 index 00000000..d034be43 --- /dev/null +++ b/apps/pre-processing-service/app/test/test_sadagu_crawl.py @@ -0,0 +1,88 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_crawl_success(): + body = { + "job_id": 1, # 문자열 -> 숫자로 수정 + "schedule_id": 1, # 문자열 -> 숫자로 수정 + "schedule_his_id": 1, + "tag": "detail", + "product_url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=886788894790", + "use_selenium": False, + "include_images": False + } + + response = client.post("/product/crawl", json=body) + print(f"Response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == body["job_id"] + assert data["schedule_id"] == body["schedule_id"] + assert data["product_url"] == body["product_url"] + assert "product_detail" in data + + +def test_crawl_invalid_url(): + """잘못된 URL이지만 페이지는 존재하는 경우""" + body = { + "job_id": 2, + "schedule_id": 2, + "schedule_his_id": 2, + "tag": "detail", + "product_url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=invalid", + "use_selenium": False, + "include_images": False + } + + response = client.post("/product/crawl", json=body) + print(f"Response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + + product_detail = data.get("product_detail", {}) + assert product_detail.get("title") in ["제목 없음", "제목 추출 실패", None] + assert product_detail.get("price", 0) == 0 + + +def test_crawl_completely_invalid_url(): + """완전히 존재하지 않는 도메인""" + body = { + "job_id": 3, + "schedule_id": 3, + "schedule_his_id": 3, + "tag": "detail", + "product_url": "https://nonexistent-domain-12345.com/invalid", + "use_selenium": False, + "include_images": False + } + + response = client.post("/product/crawl", json=body) + print(f"Response: {response.json()}") + + assert response.status_code in (400, 422, 500) + + +def test_crawl_include_images(): + body = { + "job_id": 4, + "schedule_id": 4, + "schedule_his_id": 4, + "tag": "detail", + "product_url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=886788894790", + "use_selenium": False, + "include_images": True + } + + response = client.post("/product/crawl", json=body) + print(f"Response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["include_images"] is True + assert isinstance(data["product_detail"].get("product_images"), list) \ No newline at end of file diff --git a/apps/pre-processing-service/app/test/test_search_service.py b/apps/pre-processing-service/app/test/test_search_service.py new file mode 100644 index 00000000..6dd415e0 --- /dev/null +++ b/apps/pre-processing-service/app/test/test_search_service.py @@ -0,0 +1,62 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_search_success(): + """상품 검색 성공 테스트""" + body = { + "job_id": 1, + "schedule_id": 1, + "schedule_his_id": 1, + "keyword": "반지" + } + + response = client.post("/product/search", json=body) + print(f"Search Response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == body["job_id"] + assert data["keyword"] == body["keyword"] + assert data["status"] == "success" + assert isinstance(data["search_results"], list) + + +def test_search_empty_keyword(): + """빈 키워드 검색 테스트""" + body = { + "job_id": 2, + "schedule_id": 2, + "schedule_his_id": 2, + "keyword": "" + } + + response = client.post("/product/search", json=body) + print(f"Empty keyword response: {response.json()}") + + # 빈 키워드라도 에러가 아닌 빈 결과를 반환해야 함 + assert response.status_code == 200 + data = response.json() + assert data["search_results"] == [] + + +def test_search_nonexistent_keyword(): + """존재하지 않는 키워드 검색""" + body = { + "job_id": 3, + "schedule_id": 3, + "schedule_his_id": 3, + "keyword": "zxcvbnmasdfghjklqwertyuiop123456789" + } + + response = client.post("/product/search", json=body) + print(f"Nonexistent keyword response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + # 검색 결과가 없어도 성공으로 처리 + assert data["status"] == "success" + assert isinstance(data["search_results"], list) \ No newline at end of file diff --git a/apps/pre-processing-service/app/test/test_similarity_service.py b/apps/pre-processing-service/app/test/test_similarity_service.py new file mode 100644 index 00000000..1888b873 --- /dev/null +++ b/apps/pre-processing-service/app/test/test_similarity_service.py @@ -0,0 +1,136 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_similarity_with_matched_products(): + """매칭된 상품들 중에서 유사도 분석""" + matched_products = [ + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", + "title": "925 실버 반지 여성용", + "match_info": { + "match_type": "exact", + "match_score": 1.0, + "match_reason": "완전 매칭" + } + }, + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=456", + "title": "반지 세트 커플링", + "match_info": { + "match_type": "morphological", + "match_score": 0.8, + "match_reason": "형태소 매칭" + } + } + ] + + body = { + "job_id": 1, + "schedule_id": 1, + "schedule_his_id": 1, + "keyword": "반지", + "matched_products": matched_products + } + + response = client.post("/product/similarity", json=body) + print(f"Similarity Response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == body["job_id"] + assert data["keyword"] == body["keyword"] + assert data["status"] == "success" + + if data["selected_product"]: + assert "similarity_info" in data["selected_product"] + assert "similarity_score" in data["selected_product"]["similarity_info"] + assert data["reason"] is not None + + +def test_similarity_fallback_to_search_results(): + """매칭 실패시 전체 검색 결과에서 유사도 분석""" + search_results = [ + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", + "title": "실버 링 악세서리" + }, + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=456", + "title": "골드 반지 여성" + } + ] + + body = { + "job_id": 2, + "schedule_id": 2, + "schedule_his_id": 2, + "keyword": "반지", + "matched_products": [], # 매칭된 상품 없음 + "search_results": search_results # 폴백용 + } + + response = client.post("/product/similarity", json=body) + print(f"Fallback Response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + # 폴백 모드에서는 임계값을 통과한 경우에만 상품이 선택됨 + if data["selected_product"]: + assert "similarity_info" in data["selected_product"] + assert data["selected_product"]["similarity_info"]["analysis_mode"] == "fallback_similarity_only" + + +def test_similarity_single_candidate(): + """후보가 1개만 있는 경우""" + single_product = [ + { + "url": "https://ssadagu.kr/shop/view.php?platform=1688&num_iid=123", + "title": "925 실버 반지 여성용", + "match_info": { + "match_type": "exact", + "match_score": 1.0 + } + } + ] + + body = { + "job_id": 3, + "schedule_id": 3, + "schedule_his_id": 3, + "keyword": "반지", + "matched_products": single_product + } + + response = client.post("/product/similarity", json=body) + print(f"Single candidate response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["selected_product"] is not None + assert data["selected_product"]["similarity_info"]["analysis_type"] == "single_candidate" + + +def test_similarity_no_candidates(): + """후보가 없는 경우""" + body = { + "job_id": 4, + "schedule_id": 4, + "schedule_his_id": 4, + "keyword": "반지", + "matched_products": [], + "search_results": [] + } + + response = client.post("/product/similarity", json=body) + print(f"No candidates response: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["selected_product"] is None + assert "검색 결과가 모두 없음" in data["reason"] \ No newline at end of file diff --git a/apps/pre-processing-service/app/utils/__init__.py b/apps/pre-processing-service/app/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/pre-processing-service/app/utils/crawler_utils.py b/apps/pre-processing-service/app/utils/crawler_utils.py new file mode 100644 index 00000000..8246788a --- /dev/null +++ b/apps/pre-processing-service/app/utils/crawler_utils.py @@ -0,0 +1,340 @@ +import urllib.parse +import httpx +import re +import time +from bs4 import BeautifulSoup +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.common.exceptions import TimeoutException, NoSuchElementException + + +class SearchCrawler: + def __init__(self, use_selenium=True): + self.base_url = "https://ssadagu.kr" + self.use_selenium = use_selenium + + if use_selenium: + self._setup_selenium() + else: + self._setup_httpx() + + def _setup_selenium(self): + """Selenium WebDriver 초기화""" + chrome_options = Options() + chrome_options.add_argument('--headless') + chrome_options.add_argument('--no-sandbox') + chrome_options.add_argument('--disable-dev-shm-usage') + chrome_options.add_argument('--disable-gpu') + chrome_options.add_argument('--window-size=1920,1080') + chrome_options.add_argument( + '--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ) + + try: + self.driver = webdriver.Chrome(options=chrome_options) + self.wait = WebDriverWait(self.driver, 10) + print("Selenium WebDriver 초기화 완료") + except Exception as e: + print(f"Selenium 초기화 실패, httpx로 대체: {e}") + self.use_selenium = False + self._setup_httpx() + + def _setup_httpx(self): + """httpx 클라이언트 초기화""" + self.client = httpx.AsyncClient( + headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + }, + timeout=30.0 + ) + + async def search_products_selenium(self, keyword: str) -> list[dict]: + """Selenium을 사용한 상품 검색""" + encoded_keyword = urllib.parse.quote(keyword) + search_url = f"{self.base_url}/shop/search.php?ss_tx={encoded_keyword}" + + try: + self.driver.get(search_url) + time.sleep(5) + + product_links = [] + link_elements = self.driver.find_elements(By.TAG_NAME, "a") + + for element in link_elements: + href = element.get_attribute('href') + if href and 'view.php' in href and ('platform=1688' in href or 'num_iid' in href): + try: + title = element.get_attribute('title') or element.text.strip() + if title: + product_links.append({ + 'url': href, + 'title': title + }) + except: + product_links.append({ + 'url': href, + 'title': 'Unknown Title' + }) + + # 중복 제거 + seen_urls = set() + unique_products = [] + for product in product_links: + if product['url'] not in seen_urls: + seen_urls.add(product['url']) + unique_products.append(product) + + print(f"Selenium으로 발견한 상품 링크: {len(unique_products)}개") + return unique_products[:20] + + except Exception as e: + print(f"Selenium 검색 오류: {e}") + return [] + + async def search_products_httpx(self, keyword: str) -> list[dict]: + """httpx를 사용한 상품 검색""" + encoded_keyword = urllib.parse.quote(keyword) + search_url = f"{self.base_url}/shop/search.php?ss_tx={encoded_keyword}" + + try: + response = await self.client.get(search_url) + response.raise_for_status() + soup = BeautifulSoup(response.content, 'html.parser') + + product_links = [] + all_links = soup.find_all('a', href=True) + + for link in all_links: + href = link['href'] + if 'view.php' in href and ('platform=1688' in href or 'num_iid' in href): + full_url = f"{self.base_url}{href}" if href.startswith('/') else href + title = link.get('title', '') or link.get_text(strip=True) or 'Unknown Title' + + product_links.append({ + 'url': full_url, + 'title': title + }) + + print(f"httpx로 발견한 상품 링크: {len(product_links)}개") + return product_links[:20] + + except Exception as e: + print(f"httpx 검색 오류: {e}") + return [] + + async def get_basic_product_info(self, product_url: str) -> dict: + """기본 상품 정보만 크롤링""" + try: + if self.use_selenium: + self.driver.get(product_url) + self.wait.until(lambda driver: driver.execute_script("return document.readyState") == "complete") + soup = BeautifulSoup(self.driver.page_source, 'html.parser') + else: + response = await self.client.get(product_url) + response.raise_for_status() + soup = BeautifulSoup(response.content, 'html.parser') + + title_element = soup.find('h1', {'id': 'kakaotitle'}) + title = title_element.get_text(strip=True) if title_element else "제목 없음" + + return { + 'url': product_url, + 'title': title + } + + except Exception as e: + print(f"기본 상품 크롤링 오류 ({product_url}): {e}") + return None + + async def close(self): + """리소스 정리""" + if self.use_selenium and hasattr(self, 'driver'): + try: + self.driver.quit() + except Exception: + pass + elif hasattr(self, 'client'): + try: + await self.client.aclose() + except Exception: + pass + + +class DetailCrawler(SearchCrawler): + """SearchCrawler를 확장한 상세 크롤링 클래스""" + + async def crawl_detail(self, product_url: str, include_images: bool = False) -> dict: + """상품 상세 정보 크롤링""" + try: + if self.use_selenium: + soup = await self._get_soup_selenium(product_url) + else: + soup = await self._get_soup_httpx(product_url) + + # 기본 정보 추출 + title = self._extract_title(soup) + price = self._extract_price(soup) + rating = self._extract_rating(soup) + options = self._extract_options(soup) + material_info = self._extract_material_info(soup) + + product_data = { + 'url': product_url, + 'title': title, + 'price': price, + 'rating': rating, + 'options': options, + 'material_info': material_info, + 'crawled_at': time.strftime('%Y-%m-%d %H:%M:%S') + } + + if include_images: + print("이미지 정보 추출 중...") + product_images = self._extract_images(soup) + product_data['product_images'] = [{'original_url': img_url} for img_url in product_images] + print(f"추출된 이미지: {len(product_images)}개") + else: + product_data['product_images'] = [] + + return product_data + + except Exception as e: + print(f"크롤링 오류: {e}") + raise Exception(f"크롤링 실패: {str(e)}") + + async def _get_soup_selenium(self, product_url: str) -> BeautifulSoup: + """Selenium으로 HTML 가져오기""" + try: + self.driver.get(product_url) + self.wait.until(lambda driver: driver.execute_script("return document.readyState") == "complete") + time.sleep(2) + return BeautifulSoup(self.driver.page_source, 'html.parser') + except Exception as e: + raise Exception(f"Selenium HTML 로딩 실패: {e}") + + async def _get_soup_httpx(self, product_url: str) -> BeautifulSoup: + """httpx로 HTML 가져오기""" + try: + response = await self.client.get(product_url) + response.raise_for_status() + return BeautifulSoup(response.content, 'html.parser') + except Exception as e: + raise Exception(f"HTTP 요청 실패: {e}") + + def _extract_title(self, soup: BeautifulSoup) -> str: + """제목 추출""" + title_element = soup.find('h1', {'id': 'kakaotitle'}) + return title_element.get_text(strip=True) if title_element else "제목 없음" + + def _extract_price(self, soup: BeautifulSoup) -> int: + """가격 추출""" + price = 0 + price_selectors = [ + 'span.price.gsItemPriceKWR', + '.pdt_price span.price', + 'span.price', + '.price' + ] + + for selector in price_selectors: + price_element = soup.select_one(selector) + if price_element: + price_text = price_element.get_text(strip=True).replace(',', '').replace('원', '') + price_match = re.search(r'(\d+)', price_text) + if price_match: + price = int(price_match.group(1)) + break + return price + + def _extract_rating(self, soup: BeautifulSoup) -> float: + """평점 추출""" + rating = 0.0 + star_containers = [ + soup.find('a', class_='start'), + soup.find('div', class_=re.compile(r'star|rating')), + soup.find('a', href='#reviews_wrap') + ] + + for container in star_containers: + if container: + star_imgs = container.find_all('img') + for img in star_imgs: + src = img.get('src', '') + if 'icon_star.svg' in src: + rating += 1 + elif 'icon_star_half.svg' in src: + rating += 0.5 + break + return rating + + def _extract_options(self, soup: BeautifulSoup) -> list[dict]: + """상품 옵션 추출""" + options = [] + sku_list = soup.find('ul', {'id': 'skubox'}) + + if sku_list: + option_items = sku_list.find_all('li', class_=re.compile(r'imgWrapper')) + for item in option_items: + title_element = item.find('a', title=True) + if title_element: + option_name = title_element.get('title', '').strip() + + # 재고 정보 추출 + stock = 0 + item_text = item.get_text() + stock_match = re.search(r'재고\s*:\s*(\d+)', item_text) + if stock_match: + stock = int(stock_match.group(1)) + + # 이미지 URL 추출 + img_element = item.find('img', class_='colorSpec_hashPic') + image_url = "" + if img_element and img_element.get('src'): + image_url = img_element['src'] + + if option_name: + options.append({ + 'name': option_name, + 'stock': stock, + 'image_url': image_url + }) + + return options + + def _extract_material_info(self, soup: BeautifulSoup) -> dict: + """소재 정보 추출""" + material_info = {} + info_items = soup.find_all('div', class_='pro-info-item') + + for item in info_items: + title_element = item.find('div', class_='pro-info-title') + info_element = item.find('div', class_='pro-info-info') + + if title_element and info_element: + title = title_element.get_text(strip=True) + info = info_element.get_text(strip=True) + material_info[title] = info + + return material_info + + def _extract_images(self, soup: BeautifulSoup) -> list[str]: + """상품 이미지 추출""" + images = [] + img_elements = soup.find_all('img', {'id': re.compile(r'img_translate_\d+')}) + + for img in img_elements: + src = img.get('src', '') + if src: + if src.startswith('//'): + src = 'https:' + src + elif src.startswith('/'): + src = self.base_url + src + elif src.startswith('http'): + pass + else: + continue + images.append(src) + + return images \ No newline at end of file diff --git a/apps/pre-processing-service/app/utils/crawling_util.py b/apps/pre-processing-service/app/utils/crawling_util.py new file mode 100644 index 00000000..8b0f1501 --- /dev/null +++ b/apps/pre-processing-service/app/utils/crawling_util.py @@ -0,0 +1,56 @@ +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.support.ui import WebDriverWait + +class CrawlingUtil: + + def __init__(self): + self.options = self._get_chrome_options() + self.driver = None + + def _get_chrome_options(self): + """ + 크롬 옵션 설정 + 1. 헤드리스 모드 비활성화 (네이버 탐지 우회) + 2. 샌드박스 비활성화 + 3. GPU 비활성화 + 4. 완전한 사용자 에이전트 설정 + 5. 자동화 탐지 우회 설정 + """ + + options = Options() + + options.add_argument('--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36') + # options.add_argument('--headless') 백그라운드 실행시 주석 해제 + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-gpu") + options.add_argument("--disable-extensions") + options.add_experimental_option("excludeSwitches", ["enable-automation"]) + options.add_experimental_option('useAutomationExtension', False) + options.add_argument("--disable-blink-features=AutomationControlled") + + return options + + def get_driver(self): + """ + 셀레니움 웹 드라이버 반환 + :return: 셀레니움 웹 드라이버 + """ + + if self.driver is None: + self.driver = webdriver.Chrome(options=self.options) + + return self.driver + + def get_wait(self, timeout: int = 15): + """ + WebDriverWait 객체 반환 + :param timeout: 대기 시간 (초) + :return: WebDriverWait 객체 + """ + + if self.driver is None: + self.get_driver() + + return WebDriverWait(self.driver, timeout) diff --git a/apps/pre-processing-service/app/utils/keyword_matcher.py b/apps/pre-processing-service/app/utils/keyword_matcher.py new file mode 100644 index 00000000..8fab2730 --- /dev/null +++ b/apps/pre-processing-service/app/utils/keyword_matcher.py @@ -0,0 +1,148 @@ +from app.core.config import settings # pydantic_settings 기반 + +try: + import MeCab + + print("MeCab 라이브러리 로딩 성공") + MECAB_AVAILABLE = True +except ImportError: + print("MeCab 라이브러리를 찾을 수 없습니다. pip install mecab-python3 를 실행해주세요.") + MeCab = None + MECAB_AVAILABLE = False + + +class KeywordMatcher: + """키워드 매칭 분석기""" + + def __init__(self): + self.konlpy_available = False + + # MeCab 사용 가능 여부 확인 + if MECAB_AVAILABLE: + try: + # 경로가 있으면 사용, 없으면 기본값 + if settings.mecab_path: + self.mecab = MeCab.Tagger(f"-d {settings.mecab_path}") + else: + self.mecab = MeCab.Tagger() # 기본 경로 + + # 테스트 실행 + test_result = self.mecab.parse("테스트") + if test_result and test_result.strip(): + self.konlpy_available = True + print(f"MeCab 형태소 분석기 사용 가능 (경로: {settings.mecab_path or '기본'})") + else: + print("MeCab 테스트 실패") + except Exception as e: + print(f"MeCab 사용 불가 (규칙 기반으로 대체): {e}") + else: + print("MeCab 라이브러리가 설치되지 않았습니다. 규칙 기반으로 대체합니다.") + + def analyze_keyword_match(self, title: str, keyword: str) -> dict: + """키워드 매칭 분석 결과 반환""" + title_lower = title.lower().strip() + keyword_lower = keyword.lower().strip() + + # 1. 완전 포함 검사 + exact_match = keyword_lower in title_lower + if exact_match: + return { + 'is_match': True, + 'match_type': 'exact', + 'score': 1.0, + 'reason': f"완전 포함: '{keyword}' in '{title[:50]}'" + } + + # 2. 형태소 분석 (MeCab 사용) + if self.konlpy_available: + morphological_result = self._morphological_match(title_lower, keyword_lower) + if morphological_result['is_match']: + return morphological_result + + # 3. 규칙 기반 분석 (MeCab 실패시) + simple_result = self._simple_keyword_match(title_lower, keyword_lower) + return simple_result + + def _morphological_match(self, title: str, keyword: str) -> dict: + """형태소 분석 기반 매칭""" + try: + # 키워드 형태소 분석 + keyword_result = self.mecab.parse(keyword) + keyword_morphs = [] + for line in keyword_result.split('\n'): + if line == 'EOS' or line == '': + continue + parts = line.split('\t') + if len(parts) >= 1: + morph = parts[0].strip() + if len(morph) >= 1: + keyword_morphs.append(morph) + + # 제목 형태소 분석 + title_result = self.mecab.parse(title) + title_morphs = [] + for line in title_result.split('\n'): + if line == 'EOS' or line == '': + continue + parts = line.split('\t') + if len(parts) >= 1: + morph = parts[0].strip() + if len(morph) >= 1: + title_morphs.append(morph) + + # 형태소 매칭 + matched = 0 + for kw in keyword_morphs: + if len(kw) >= 2: # 의미있는 형태소만 검사 + for tw in title_morphs: + if kw == tw or kw in tw or tw in kw: + matched += 1 + break + + match_ratio = matched / len(keyword_morphs) if keyword_morphs else 0 + threshold = 0.4 + + if match_ratio >= threshold: + return { + 'is_match': True, + 'match_type': 'morphological', + 'score': match_ratio, + 'reason': f"형태소 매칭: {matched}/{len(keyword_morphs)} = {match_ratio:.3f}" + } + + except Exception as e: + print(f"형태소 분석 오류: {e}") + + return {'is_match': False, 'match_type': 'morphological', 'score': 0.0, 'reason': '형태소 분석 실패'} + + def _simple_keyword_match(self, title: str, keyword: str) -> dict: + """간단한 키워드 매칭""" + # 공백으로 분리 + title_words = title.split() + keyword_words = keyword.split() + + matched = 0 + for kw in keyword_words: + if len(kw) >= 2: + for tw in title_words: + if kw in tw or tw in kw: + matched += 1 + break + + match_ratio = matched / len(keyword_words) if keyword_words else 0 + threshold = 0.3 + + if match_ratio >= threshold: + return { + 'is_match': True, + 'match_type': 'simple', + 'score': match_ratio, + 'reason': f"규칙 기반 매칭: {matched}/{len(keyword_words)} = {match_ratio:.3f}" + } + + return { + 'is_match': False, + 'match_type': 'simple', + 'score': match_ratio, + 'reason': f"규칙 기반 미달: {matched}/{len(keyword_words)} = {match_ratio:.3f} < {threshold}" + } \ No newline at end of file diff --git a/apps/pre-processing-service/app/utils/similarity_analyzer.py b/apps/pre-processing-service/app/utils/similarity_analyzer.py new file mode 100644 index 00000000..d155ee2e --- /dev/null +++ b/apps/pre-processing-service/app/utils/similarity_analyzer.py @@ -0,0 +1,65 @@ +import torch +import numpy as np +from sklearn.metrics.pairwise import cosine_similarity +from transformers import AutoTokenizer, AutoModel + + +class SimilarityAnalyzer: + """텍스트 유사도 분석기""" + + def __init__(self): + try: + self.tokenizer = AutoTokenizer.from_pretrained('klue/bert-base') + self.model = AutoModel.from_pretrained('klue/bert-base') + print("KLUE BERT 모델 로딩 성공") + except Exception as e: + print(f"KLUE BERT 로딩 실패, 다국어 BERT로 대체: {e}") + try: + self.tokenizer = AutoTokenizer.from_pretrained('bert-base-multilingual-cased') + self.model = AutoModel.from_pretrained('bert-base-multilingual-cased') + print("다국어 BERT 모델 로딩 성공") + except Exception as e2: + print(f"모든 BERT 모델 로딩 실패: {e2}") + raise e2 + + def get_embedding(self, text: str) -> np.ndarray: + """텍스트 임베딩 생성""" + inputs = self.tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=128) + with torch.no_grad(): + outputs = self.model(**inputs) + return outputs.last_hidden_state[:, 0, :].numpy() + + def calculate_similarity(self, text1: str, text2: str) -> float: + """두 텍스트 간 유사도 계산""" + embedding1 = self.get_embedding(text1) + embedding2 = self.get_embedding(text2) + return cosine_similarity(embedding1, embedding2)[0][0] + + def analyze_similarity_batch(self, keyword: str, product_titles: list[str]) -> list[dict]: + """배치로 유사도 분석""" + keyword_embedding = self.get_embedding(keyword) + results = [] + + for i, title in enumerate(product_titles): + try: + title_embedding = self.get_embedding(title) + similarity = cosine_similarity(keyword_embedding, title_embedding)[0][0] + + results.append({ + 'index': i, + 'title': title, + 'similarity': float(similarity), + 'score': float(similarity) + }) + except Exception as e: + print(f"유사도 계산 오류 (제목: {title[:30]}): {e}") + results.append({ + 'index': i, + 'title': title, + 'similarity': 0.0, + 'score': 0.0 + }) + + # 유사도 기준 내림차순 정렬 + results.sort(key=lambda x: x['similarity'], reverse=True) + return results \ No newline at end of file diff --git a/apps/pre-processing-service/poetry.lock b/apps/pre-processing-service/poetry.lock index 961f44e5..30f79248 100644 --- a/apps/pre-processing-service/poetry.lock +++ b/apps/pre-processing-service/poetry.lock @@ -32,6 +32,310 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.26.1)"] +[[package]] +name = "asyncpg" +version = "0.30.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "asyncpg-0.30.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfb4dd5ae0699bad2b233672c8fc5ccbd9ad24b89afded02341786887e37927e"}, + {file = "asyncpg-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc1f62c792752a49f88b7e6f774c26077091b44caceb1983509edc18a2222ec0"}, + {file = "asyncpg-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3152fef2e265c9c24eec4ee3d22b4f4d2703d30614b0b6753e9ed4115c8a146f"}, + {file = "asyncpg-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7255812ac85099a0e1ffb81b10dc477b9973345793776b128a23e60148dd1af"}, + {file = "asyncpg-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:578445f09f45d1ad7abddbff2a3c7f7c291738fdae0abffbeb737d3fc3ab8b75"}, + {file = "asyncpg-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c42f6bb65a277ce4d93f3fba46b91a265631c8df7250592dd4f11f8b0152150f"}, + {file = "asyncpg-0.30.0-cp310-cp310-win32.whl", hash = "sha256:aa403147d3e07a267ada2ae34dfc9324e67ccc4cdca35261c8c22792ba2b10cf"}, + {file = "asyncpg-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb622c94db4e13137c4c7f98834185049cc50ee01d8f657ef898b6407c7b9c50"}, + {file = "asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a"}, + {file = "asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed"}, + {file = "asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a"}, + {file = "asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956"}, + {file = "asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056"}, + {file = "asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454"}, + {file = "asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d"}, + {file = "asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f"}, + {file = "asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e"}, + {file = "asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a"}, + {file = "asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3"}, + {file = "asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737"}, + {file = "asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a"}, + {file = "asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af"}, + {file = "asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e"}, + {file = "asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305"}, + {file = "asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70"}, + {file = "asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3"}, + {file = "asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33"}, + {file = "asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4"}, + {file = "asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4"}, + {file = "asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba"}, + {file = "asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590"}, + {file = "asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e"}, + {file = "asyncpg-0.30.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:29ff1fc8b5bf724273782ff8b4f57b0f8220a1b2324184846b39d1ab4122031d"}, + {file = "asyncpg-0.30.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64e899bce0600871b55368b8483e5e3e7f1860c9482e7f12e0a771e747988168"}, + {file = "asyncpg-0.30.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b290f4726a887f75dcd1b3006f484252db37602313f806e9ffc4e5996cfe5cb"}, + {file = "asyncpg-0.30.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f86b0e2cd3f1249d6fe6fd6cfe0cd4538ba994e2d8249c0491925629b9104d0f"}, + {file = "asyncpg-0.30.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:393af4e3214c8fa4c7b86da6364384c0d1b3298d45803375572f415b6f673f38"}, + {file = "asyncpg-0.30.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fd4406d09208d5b4a14db9a9dbb311b6d7aeeab57bded7ed2f8ea41aeef39b34"}, + {file = "asyncpg-0.30.0-cp38-cp38-win32.whl", hash = "sha256:0b448f0150e1c3b96cb0438a0d0aa4871f1472e58de14a3ec320dbb2798fb0d4"}, + {file = "asyncpg-0.30.0-cp38-cp38-win_amd64.whl", hash = "sha256:f23b836dd90bea21104f69547923a02b167d999ce053f3d502081acea2fba15b"}, + {file = "asyncpg-0.30.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f4e83f067b35ab5e6371f8a4c93296e0439857b4569850b178a01385e82e9ad"}, + {file = "asyncpg-0.30.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5df69d55add4efcd25ea2a3b02025b669a285b767bfbf06e356d68dbce4234ff"}, + {file = "asyncpg-0.30.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3479a0d9a852c7c84e822c073622baca862d1217b10a02dd57ee4a7a081f708"}, + {file = "asyncpg-0.30.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26683d3b9a62836fad771a18ecf4659a30f348a561279d6227dab96182f46144"}, + {file = "asyncpg-0.30.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1b982daf2441a0ed314bd10817f1606f1c28b1136abd9e4f11335358c2c631cb"}, + {file = "asyncpg-0.30.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1c06a3a50d014b303e5f6fc1e5f95eb28d2cee89cf58384b700da621e5d5e547"}, + {file = "asyncpg-0.30.0-cp39-cp39-win32.whl", hash = "sha256:1b11a555a198b08f5c4baa8f8231c74a366d190755aa4f99aacec5970afe929a"}, + {file = "asyncpg-0.30.0-cp39-cp39-win_amd64.whl", hash = "sha256:8b684a3c858a83cd876f05958823b68e8d14ec01bb0c0d14a6704c5bf9711773"}, + {file = "asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851"}, +] + +[package.extras] +docs = ["Sphinx (>=8.1.3,<8.2.0)", "sphinx-rtd-theme (>=1.2.2)"] +gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""] +test = ["distro (>=1.9.0,<1.10.0)", "flake8 (>=6.1,<7.0)", "flake8-pyi (>=24.1.0,<24.2.0)", "gssapi ; platform_system == \"Linux\"", "k5test ; platform_system == \"Linux\"", "mypy (>=1.8.0,<1.9.0)", "sspilib ; platform_system == \"Windows\"", "uvloop (>=0.15.3) ; platform_system != \"Windows\" and python_version < \"3.14.0\""] + +[[package]] +name = "attrs" +version = "25.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + +[[package]] +name = "beautifulsoup4" +version = "4.13.5" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a"}, + {file = "beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695"}, +] + +[package.dependencies] +soupsieve = ">1.2" +typing-extensions = ">=4.0.0" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "bs4" +version = "0.0.2" +description = "Dummy package for Beautiful Soup (beautifulsoup4)" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"}, + {file = "bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925"}, +] + +[package.dependencies] +beautifulsoup4 = "*" + +[[package]] +name = "certifi" +version = "2025.8.3" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "os_name == \"nt\" and implementation_name != \"pypy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, + {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, + {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, +] + [[package]] name = "click" version = "8.2.1" @@ -96,6 +400,80 @@ all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (> standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "filelock" +version = "3.19.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, + {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, +] + +[[package]] +name = "fsspec" +version = "2025.9.0" +description = "File-system specification" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7"}, + {file = "fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19"}, +] + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +dev = ["pre-commit", "ruff (>=0.5)"] +doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] +test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] +test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] +tqdm = ["tqdm"] + +[[package]] +name = "gunicorn" +version = "23.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + [[package]] name = "h11" version = "0.16.0" @@ -108,6 +486,114 @@ files = [ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "hf-xet" +version = "1.1.9" +description = "Fast transfer of large files with the Hugging Face Hub." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"" +files = [ + {file = "hf_xet-1.1.9-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a3b6215f88638dd7a6ff82cb4e738dcbf3d863bf667997c093a3c990337d1160"}, + {file = "hf_xet-1.1.9-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:9b486de7a64a66f9a172f4b3e0dfe79c9f0a93257c501296a2521a13495a698a"}, + {file = "hf_xet-1.1.9-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c5a840c2c4e6ec875ed13703a60e3523bc7f48031dfd750923b2a4d1a5fc3c"}, + {file = "hf_xet-1.1.9-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:96a6139c9e44dad1c52c52520db0fffe948f6bce487cfb9d69c125f254bb3790"}, + {file = "hf_xet-1.1.9-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ad1022e9a998e784c97b2173965d07fe33ee26e4594770b7785a8cc8f922cd95"}, + {file = "hf_xet-1.1.9-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86754c2d6d5afb11b0a435e6e18911a4199262fe77553f8c50d75e21242193ea"}, + {file = "hf_xet-1.1.9-cp37-abi3-win_amd64.whl", hash = "sha256:5aad3933de6b725d61d51034e04174ed1dce7a57c63d530df0014dea15a40127"}, + {file = "hf_xet-1.1.9.tar.gz", hash = "sha256:c99073ce404462e909f1d5839b2d14a3827b8fe75ed8aed551ba6609c026c803"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "huggingface-hub" +version = "0.34.4" +description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a"}, + {file = "huggingface_hub-0.34.4.tar.gz", hash = "sha256:a4228daa6fb001be3f4f4bdaf9a0db00e1739235702848df00885c9b5742c85c"}, +] + +[package.dependencies] +filelock = "*" +fsspec = ">=2023.5.0" +hf-xet = {version = ">=1.1.3,<2.0.0", markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""} +packaging = ">=20.9" +pyyaml = ">=5.1" +requests = "*" +tqdm = ">=4.42.1" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +cli = ["InquirerPy (==0.3.4)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-transfer = ["hf-transfer (>=0.1.4)"] +hf-xet = ["hf-xet (>=1.1.2,<2.0.0)"] +inference = ["aiohttp"] +mcp = ["aiohttp", "mcp (>=1.8.0)", "typer"] +oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"] +quality = ["libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "ruff (>=0.9.0)"] +tensorflow = ["graphviz", "pydot", "tensorflow"] +tensorflow-testing = ["keras (<3.0)", "tensorflow"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +torch = ["safetensors[torch]", "torch"] +typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] + [[package]] name = "idna" version = "3.10" @@ -128,31 +614,531 @@ name = "iniconfig" version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "joblib" +version = "1.5.2" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"}, + {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, +] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "mecab-python3" +version = "1.0.10" +description = "Python wrapper for the MeCab morphological analyzer for Japanese" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "mecab_python3-1.0.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ddeeb7e40348066cbcf980dffa19bc84e087bb0fb452ce149defc11747f52f85"}, + {file = "mecab_python3-1.0.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1acb9f47108170a43549637f3f45449c7018d56e91ca5fc8ad56bbcd8288848c"}, + {file = "mecab_python3-1.0.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e487498dc7231926230944ad04e40406d23499240fd35273d8d2c4f775dcc162"}, + {file = "mecab_python3-1.0.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a2924d9ee1a7eefe0601edf16d2b63c5519e3403b319cfc9d1eda4bf978f6d9"}, + {file = "mecab_python3-1.0.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:069c176c02b6bec3fdc9e00c42138dc77ef4b683908b6909808bc7528d2996bc"}, + {file = "mecab_python3-1.0.10-cp310-cp310-win_amd64.whl", hash = "sha256:63cd0a65835257a1fcb88f25a6eaf1a8e472990a9d3f7d08300c5cccf8973931"}, + {file = "mecab_python3-1.0.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ebc8bbdb7e0c616e1467b02cadc3c7a764912dec241b31a14c90b1c1ac58afc8"}, + {file = "mecab_python3-1.0.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d66bbda223e64bf1eb320809b5d7e21fc6b045ccc14e07232d8592dd40b1a29"}, + {file = "mecab_python3-1.0.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eed9b626a82eb26e571e45832b7c03b46e250e57c70d7309aa0c28c0fb95d47"}, + {file = "mecab_python3-1.0.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01c1123fb64fb67d29e7221a9cba36b589b795683bd94e762d87385a9633de95"}, + {file = "mecab_python3-1.0.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da59058da7459457f14382ddb6a2bb4a80176d0dcfa3eb835c53abd11e5aa97d"}, + {file = "mecab_python3-1.0.10-cp311-cp311-win_amd64.whl", hash = "sha256:1eca068650d9f228072820ce015eb5831b9114afe6cc0f381208eaa2e1f23f0f"}, + {file = "mecab_python3-1.0.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb21b38fea3da3a3c893b6af34f9d34e4846c30f7d2f76fe58beee195963fbf3"}, + {file = "mecab_python3-1.0.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7fa96813dca31ad1517a1c5921b5620713fdefea072795ec9de31425fcf2c4e1"}, + {file = "mecab_python3-1.0.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:134e2c68a783f545bc8373601469d017a13d9b7cac46d243ec1bbfb2c94639a3"}, + {file = "mecab_python3-1.0.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d9ff1c7a7ec4f42c98d74db71bc9b1d513db4cf676a023665ae40197f2da040"}, + {file = "mecab_python3-1.0.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2953a3e53fa269f2e1b109de3a55fc7668e9e566f0340a69c2202a37f0447691"}, + {file = "mecab_python3-1.0.10-cp312-cp312-win_amd64.whl", hash = "sha256:308cbec46e426d53bde1f97a95ea655d3e6fcababe0c444dd74c9d3f8105a179"}, + {file = "mecab_python3-1.0.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:711ee9a7ba27aa6988b580951671e5966d7b9aa16cae453d17a5e149d295941c"}, + {file = "mecab_python3-1.0.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cc4e90d23b57e1ea4bf0ecc57cf7cdbc432164398b67ad113256bc20ed52154"}, + {file = "mecab_python3-1.0.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b90261aa514f29c6e05bd99717a02eeb9be3d7ea0a0be01f65ce0d86c572c68"}, + {file = "mecab_python3-1.0.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894d87d708545314359cfd1b062238c2756d8f985b4c3fe7cabdf111f533a367"}, + {file = "mecab_python3-1.0.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bd7b86dd39a068dadf06ba8dd717ec1defb2cea181c5cbc6c54b1adb6dd0d4b"}, + {file = "mecab_python3-1.0.10-cp313-cp313-win_amd64.whl", hash = "sha256:3528ef81cc4c9506ae3b273958fe2314aa1022a8db64640e631e09fd3e1af97b"}, + {file = "mecab_python3-1.0.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c7da20d1ede231645e2be96a202f15419cb508b4b21f3c466bc5848f5956af27"}, + {file = "mecab_python3-1.0.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1e385987f2ef3f617ee87bf2ca555e10c468c156c71bfcac7182202df261f4d2"}, + {file = "mecab_python3-1.0.10-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9666fa3e116768d81c3d20f13bb05daf8a474919312cc5239180ed6f5c318e80"}, + {file = "mecab_python3-1.0.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:06f9259bd2ffb4a71e99712ea845b579674a2be7b245b88b03f28a390ab13dea"}, + {file = "mecab_python3-1.0.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8d16cf163c9fe568a42a31e99a60cdaa97d76124d04a8daa2bb2b93f18d08107"}, + {file = "mecab_python3-1.0.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82c175f1ae970b3baa3589e29f3946a1a83b76a48245ea103558abdbfb3398b1"}, + {file = "mecab_python3-1.0.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a20e0c94bb24e36ec73d1e6ae91608cd913174f8aa1d8195b218d2d77aeb3ccc"}, + {file = "mecab_python3-1.0.10-cp38-cp38-win_amd64.whl", hash = "sha256:9af3ef731dfbd1f0a97f4a91ebfea2454dc3e8fa9e42423912eb6628f2acdfec"}, + {file = "mecab_python3-1.0.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c72f1c4c582f7f86aa9454694719e7873f80830e300df0d71f6b38ff9c0f94ea"}, + {file = "mecab_python3-1.0.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c45dd85ee584326d23e1ec4d6d4c23ac39e88e5bc0442b4e81b178c59d1c148"}, + {file = "mecab_python3-1.0.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b6845fb4bf4771018a10a6c455dc4cb3e0590c8ac55d25cddfe85138a72bbd2"}, + {file = "mecab_python3-1.0.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30f4cca5992c7c5ac3767d6e21c235d02104eb11b94b60361494509a72d92a5"}, + {file = "mecab_python3-1.0.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bd723e757d321135d38ab383639c148e20eb65468517398fff26eb89344d0b5"}, + {file = "mecab_python3-1.0.10-cp39-cp39-win_amd64.whl", hash = "sha256:f93201fa2c4d7e03b3cc25ffd52a8c4ee207db874258d5143ece8b457e22a885"}, + {file = "mecab_python3-1.0.10.tar.gz", hash = "sha256:21cd4416043e9a993fcfb986dde93e4366a07543dd95849b5ef2e50c9a9afcce"}, +] + +[package.extras] +unidic = ["unidic"] +unidic-lite = ["unidic-lite"] + +[[package]] +name = "mpmath" +version = "1.3.0" +description = "Python library for arbitrary-precision floating-point arithmetic" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, + {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, +] + +[package.extras] +develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] +docs = ["sphinx"] +gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] +tests = ["pytest (>=4.6)"] + +[[package]] +name = "networkx" +version = "3.5" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec"}, + {file = "networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037"}, +] + +[package.extras] +default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"] +developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"] +doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"] +example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] +extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] +test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] +test-extras = ["pytest-mpl", "pytest-randomly"] + +[[package]] +name = "numpy" +version = "2.3.2" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9"}, + {file = "numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168"}, + {file = "numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b"}, + {file = "numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8"}, + {file = "numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d"}, + {file = "numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3"}, + {file = "numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f"}, + {file = "numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097"}, + {file = "numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220"}, + {file = "numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170"}, + {file = "numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89"}, + {file = "numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b"}, + {file = "numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f"}, + {file = "numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0"}, + {file = "numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b"}, + {file = "numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370"}, + {file = "numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73"}, + {file = "numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc"}, + {file = "numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be"}, + {file = "numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036"}, + {file = "numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f"}, + {file = "numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07"}, + {file = "numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3"}, + {file = "numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b"}, + {file = "numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6"}, + {file = "numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089"}, + {file = "numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2"}, + {file = "numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f"}, + {file = "numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee"}, + {file = "numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6"}, + {file = "numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b"}, + {file = "numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56"}, + {file = "numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2"}, + {file = "numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab"}, + {file = "numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2"}, + {file = "numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a"}, + {file = "numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286"}, + {file = "numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8"}, + {file = "numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a"}, + {file = "numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91"}, + {file = "numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5"}, + {file = "numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5"}, + {file = "numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450"}, + {file = "numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a"}, + {file = "numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a"}, + {file = "numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b"}, + {file = "numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125"}, + {file = "numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19"}, + {file = "numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f"}, + {file = "numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5"}, + {file = "numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58"}, + {file = "numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0"}, + {file = "numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2"}, + {file = "numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b"}, + {file = "numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910"}, + {file = "numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e"}, + {file = "numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45"}, + {file = "numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b"}, + {file = "numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2"}, + {file = "numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0"}, + {file = "numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0"}, + {file = "numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2"}, + {file = "numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf"}, + {file = "numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1"}, + {file = "numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b"}, + {file = "numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631"}, + {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15"}, + {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec"}, + {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712"}, + {file = "numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c"}, + {file = "numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296"}, + {file = "numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981"}, + {file = "numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619"}, + {file = "numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48"}, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +description = "CUBLAS native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b86f6dd8935884615a0683b663891d43781b819ac4f2ba2b0c9604676af346d0"}, + {file = "nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142"}, + {file = "nvidia_cublas_cu12-12.8.4.1-py3-none-win_amd64.whl", hash = "sha256:47e9b82132fa8d2b4944e708049229601448aaad7e6f296f630f2d1a32de35af"}, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +description = "CUDA profiling tools runtime libs." +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4412396548808ddfed3f17a467b104ba7751e6b58678a4b840675c56d21cf7ed"}, + {file = "nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182"}, + {file = "nvidia_cuda_cupti_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:bb479dcdf7e6d4f8b0b01b115260399bf34154a1a2e9fe11c85c517d87efd98e"}, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +description = "NVRTC native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994"}, + {file = "nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1fec1e1637854b4c0a65fb9a8346b51dd9ee69e61ebaccc82058441f15bce8"}, + {file = "nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:7a4b6b2904850fe78e0bd179c4b655c404d4bb799ef03ddc60804247099ae909"}, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +description = "CUDA Runtime native Libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52bf7bbee900262ffefe5e9d5a2a69a30d97e2bc5bb6cc866688caa976966e3d"}, + {file = "nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90"}, + {file = "nvidia_cuda_runtime_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:c0c6027f01505bfed6c3b21ec546f69c687689aad5f1a377554bc6ca4aa993a8"}, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +description = "cuDNN runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8"}, + {file = "nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8"}, + {file = "nvidia_cudnn_cu12-9.10.2.21-py3-none-win_amd64.whl", hash = "sha256:c6288de7d63e6cf62988f0923f96dc339cea362decb1bf5b3141883392a7d65e"}, +] + +[package.dependencies] +nvidia-cublas-cu12 = "*" + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +description = "CUFFT native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a"}, + {file = "nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74"}, + {file = "nvidia_cufft_cu12-11.3.3.83-py3-none-win_amd64.whl", hash = "sha256:7a64a98ef2a7c47f905aaf8931b69a3a43f27c55530c698bb2ed7c75c0b42cb7"}, +] + +[package.dependencies] +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +description = "cuFile GPUDirect libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc"}, + {file = "nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:4beb6d4cce47c1a0f1013d72e02b0994730359e17801d395bdcbf20cfb3bb00a"}, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +description = "CURAND native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dfab99248034673b779bc6decafdc3404a8a6f502462201f2f31f11354204acd"}, + {file = "nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9"}, + {file = "nvidia_curand_cu12-10.3.9.90-py3-none-win_amd64.whl", hash = "sha256:f149a8ca457277da854f89cf282d6ef43176861926c7ac85b2a0fbd237c587ec"}, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +description = "CUDA solver native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0"}, + {file = "nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450"}, + {file = "nvidia_cusolver_cu12-11.7.3.90-py3-none-win_amd64.whl", hash = "sha256:4a550db115fcabc4d495eb7d39ac8b58d4ab5d8e63274d3754df1c0ad6a22d34"}, +] + +[package.dependencies] +nvidia-cublas-cu12 = "*" +nvidia-cusparse-cu12 = "*" +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +description = "CUSPARSE native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc"}, + {file = "nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b"}, + {file = "nvidia_cusparse_cu12-12.5.8.93-py3-none-win_amd64.whl", hash = "sha256:9a33604331cb2cac199f2e7f5104dfbb8a5a898c367a53dfda9ff2acb6b6b4dd"}, +] + +[package.dependencies] +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +description = "NVIDIA cuSPARSELt" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5"}, + {file = "nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623"}, + {file = "nvidia_cusparselt_cu12-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f67fbb5831940ec829c9117b7f33807db9f9678dc2a617fbe781cac17b4e1075"}, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.27.3" +description = "NVIDIA Collective Communication Library (NCCL) Runtime" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ddf1a245abc36c550870f26d537a9b6087fb2e2e3d6e0ef03374c6fd19d984f"}, + {file = "nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039"}, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +description = "Nvidia JIT LTO Library" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88"}, + {file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7"}, + {file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:bd93fbeeee850917903583587f4fc3a4eafa022e34572251368238ab5e6bd67f"}, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +description = "NVIDIA Tools Extension" +optional = false +python-versions = ">=3" groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7ad891da111ebafbf7e015d34879f7112832fc239ff0d7d776b6cb685274615"}, + {file = "nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f"}, + {file = "nvidia_nvtx_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:619c8304aedc69f02ea82dd244541a83c3d9d40993381b3b590f1adaed3db41e"}, ] [[package]] -name = "loguru" -version = "0.7.3" -description = "Python logging made (stupidly) simple" +name = "outcome" +version = "1.3.0.post0" +description = "Capture the outcome of Python function calls." optional = false -python-versions = "<4.0,>=3.5" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, - {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, + {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, + {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, ] [package.dependencies] -colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} -win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} - -[package.extras] -dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] +attrs = ">=19.2.0" [[package]] name = "packaging" @@ -182,6 +1168,97 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, +] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "os_name == \"nt\" and implementation_name != \"pypy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -355,6 +1432,19 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + [[package]] name = "pytest" version = "8.4.1" @@ -392,6 +1482,464 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-mecab-ko" +version = "1.3.7" +description = "A python binding for mecab-ko" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "python_mecab_ko-1.3.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4760efe6327b5707f55db2b4a6f8fb047fe8e068577a9a913304bb0d12e7de44"}, + {file = "python_mecab_ko-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:27a03ae50aabc7f057c26ad5e4c6c4d431cf696778e45025e208d2f6b7bf115d"}, + {file = "python_mecab_ko-1.3.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8d2539e7ea91eb0705381f75e64c626be4eba69824a8c82fbdf2c4e48a1d389"}, + {file = "python_mecab_ko-1.3.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2bad59670b280548b9060c1b511f6f088c09b977355de7192e9d0044b8f724b"}, + {file = "python_mecab_ko-1.3.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8c4347f075b8748cbc5695f6b91120b0e388344eab5d9c26d50ad3c57c35754"}, + {file = "python_mecab_ko-1.3.7-cp310-cp310-win32.whl", hash = "sha256:682875cd1cafeeb2946b856b1b479144b4e8d28363b6bff3ae1c8b294994742b"}, + {file = "python_mecab_ko-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:ef5a6bb8d4611dd621436492adb140c280fe4e155097c5dcc8b1fcdd203abfb6"}, + {file = "python_mecab_ko-1.3.7-cp310-cp310-win_arm64.whl", hash = "sha256:14b070b886d864964710c6a396556d8509be2dce1618f401192fd7c213eb4608"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da1cc9de07e75beb2d4067c1c072ecabdb293440633fc0e32f2875a14e703829"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:523153de14262c413838852742541d48ad99d41ab8f6c5413a226319ee4c25ef"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:010ca2297e63d08a772466dd401d36ed9914502b8794c08948427a4083b3202c"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7b35116e98fb736f7c9550eb1a74cfb6aa35c39b0b43cbe7a8837bfa3cd39d4"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0933d3fcb84f6ed36cce49f1939604ac0fcaf4460441e832cb98ca1bdce74a37"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-win32.whl", hash = "sha256:644207821de8c76ff2442d84c8902dd16b239fdc80c79d0774f8b9ea446c4218"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:a456e40817dc73f58d7f11ff01af4394cdd1ceab2e98feddde625587603d65f7"}, + {file = "python_mecab_ko-1.3.7-cp311-cp311-win_arm64.whl", hash = "sha256:9f5e40101426b87c99ecb1268f56402f9c44f9d06271b28ccc1ec1bc6bc582ac"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7721f69381dac572a1598e5906cc5faba233ed48bc6ff8672082a519d7db0ba1"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eae5eb6178b06019e3773e9dde126dd29df5ed417406be5611ebdd0f8839c1e1"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e90e8c1009f8f6aa0dfc43c916ff481dc79aa5a7e528a41a193add9c61ac6d1"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a205ca4da908df39d6d70f968426d0e9dc79274a6d34b13a5588ab52f0e12be8"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c0e98a7d94278f4f5d93f03e35cc8044460c0076ab4698b764d5c44bd897dbe"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-win32.whl", hash = "sha256:3145c53772e842a046fdbf0659f0e5235e16d51b0bb8c0d3e8e078dc57d22373"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:3387906e66109989603b877899d1ae3a0132795c9c73ad91a5e7c4c077177351"}, + {file = "python_mecab_ko-1.3.7-cp312-cp312-win_arm64.whl", hash = "sha256:13126509630e47fc89a8c575f5af3eed1bc09370e978b331caf32325e6b98383"}, + {file = "python_mecab_ko-1.3.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198c0b9a832966927ceceda599b8d2f38426d11d25defa0d4ed819e3d00bfa91"}, + {file = "python_mecab_ko-1.3.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:661da586a6783cd60dc93ebb4dcc182e5cb3d37b98d25fe741c8eb2aabd59b30"}, + {file = "python_mecab_ko-1.3.7-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eab31739769b1ad90fcd81f7e2319f2bc33f7b85aee3a5cec230352963678ac0"}, + {file = "python_mecab_ko-1.3.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10ea3c549eac11cdf9e994ce65fb34653a142d04eaa519c2ba3a99646cb21991"}, + {file = "python_mecab_ko-1.3.7-cp37-cp37m-win32.whl", hash = "sha256:ec22b9f8b7d5ec62d2af48d252f0172e1c4dfdf1387bad356f62b73084bac675"}, + {file = "python_mecab_ko-1.3.7-cp37-cp37m-win_amd64.whl", hash = "sha256:e0fb84a0eda5f77dbb456fb7eba9715349668b2a9bb4235df0904620653eabda"}, + {file = "python_mecab_ko-1.3.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ad754804a5a5b64b62d77a962d33ef6e931765cede89f880e02e3d18971a5bd"}, + {file = "python_mecab_ko-1.3.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64346e4a627ad3b56647f2d6909ba52bd25b5b29f8d320944ed9dce602ba0b75"}, + {file = "python_mecab_ko-1.3.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0b50438fb570299bd7e4c30549373c171b94f6400c32b0b455b37047e5ed7ed"}, + {file = "python_mecab_ko-1.3.7-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4321180be1e5446bb97e8f803079deb72500af7bbb7d0e2c49ec9995ec3674f5"}, + {file = "python_mecab_ko-1.3.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8c297e6e5a8a0aacd75e9efee465d0bf7f6d1b9f0ccb9b18916e9203ea0e349"}, + {file = "python_mecab_ko-1.3.7-cp38-cp38-win32.whl", hash = "sha256:8015778e03186f8d2e7b0f1c0c9b753617d848cea2c4eba09e59e081080da92a"}, + {file = "python_mecab_ko-1.3.7-cp38-cp38-win_amd64.whl", hash = "sha256:782bf38e817ad54ca16dccd2e4edf083829e259aac1da3187ccc1fd305dfb503"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4fdae16e907470cec155721cc0f849a9d52e01eae316aae53101fa236069505b"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:691bed2317e4cbbf4f00fc11a59d6d95412b72b9bd6eea037880df95fcd7e6a0"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d147ce60440cd04e3e113508f1c7f04ed39bcbb7991921d9c66b060709af253e"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99f02fb9816dda3258726b33423f0b48429582d4386529c08caa01c0d4e8365b"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c96c719105eae24c24882fbea821df7a26c961590d06ff932599690785d7efe5"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-win32.whl", hash = "sha256:288ff89e4d1318923acecccfbb0b9d4937a8f93ac27e4868e08c778629d0522a"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-win_amd64.whl", hash = "sha256:12c4b86041350024355d51dd16cb989fd027e142c8083d3b12d21b9262522054"}, + {file = "python_mecab_ko-1.3.7-cp39-cp39-win_arm64.whl", hash = "sha256:2a84df563961a6507e170f78b010716a69874fc4b00ce503280f5eb7d62ccd1c"}, + {file = "python_mecab_ko-1.3.7.tar.gz", hash = "sha256:69cbb2ac559a3169c22b1a3aa5d3c247d2f7902d9fe7dc9966189a9c7694af0b"}, +] + +[package.dependencies] +python-mecab-ko-dic = "*" + +[[package]] +name = "python-mecab-ko-dic" +version = "2.1.1.post2" +description = "mecab-ko-dic packaged for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python-mecab-ko-dic-2.1.1.post2.tar.gz", hash = "sha256:2c423713bdc475345ec98cd084b30759458f8f06c38a9ef94ab8687942c2cd34"}, + {file = "python_mecab_ko_dic-2.1.1.post2-py3-none-any.whl", hash = "sha256:ef8f4e80c8976f1340a7264abb0c96f384fe059fd897584aeba0151753c6ae9b"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "regex" +version = "2025.9.1" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "regex-2025.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5aa2a6a73bf218515484b36a0d20c6ad9dc63f6339ff6224147b0e2c095ee55"}, + {file = "regex-2025.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c2ff5c01d5e47ad5fc9d31bcd61e78c2fa0068ed00cab86b7320214446da766"}, + {file = "regex-2025.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d49dc84e796b666181de8a9973284cad6616335f01b52bf099643253094920fc"}, + {file = "regex-2025.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9914fe1040874f83c15fcea86d94ea54091b0666eab330aaab69e30d106aabe"}, + {file = "regex-2025.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e71bceb3947362ec5eabd2ca0870bb78eae4edfc60c6c21495133c01b6cd2df4"}, + {file = "regex-2025.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67a74456f410fe5e869239ee7a5423510fe5121549af133809d9591a8075893f"}, + {file = "regex-2025.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c3b96ed0223b32dbdc53a83149b6de7ca3acd5acd9c8e64b42a166228abe29c"}, + {file = "regex-2025.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:113d5aa950f428faf46fd77d452df62ebb4cc6531cb619f6cc30a369d326bfbd"}, + {file = "regex-2025.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fcdeb38de4f7f3d69d798f4f371189061446792a84e7c92b50054c87aae9c07c"}, + {file = "regex-2025.9.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4bcdff370509164b67a6c8ec23c9fb40797b72a014766fdc159bb809bd74f7d8"}, + {file = "regex-2025.9.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:7383efdf6e8e8c61d85e00cfb2e2e18da1a621b8bfb4b0f1c2747db57b942b8f"}, + {file = "regex-2025.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ec2bd3bdf0f73f7e9f48dca550ba7d973692d5e5e9a90ac42cc5f16c4432d8b"}, + {file = "regex-2025.9.1-cp310-cp310-win32.whl", hash = "sha256:9627e887116c4e9c0986d5c3b4f52bcfe3df09850b704f62ec3cbf177a0ae374"}, + {file = "regex-2025.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:94533e32dc0065eca43912ee6649c90ea0681d59f56d43c45b5bcda9a740b3dd"}, + {file = "regex-2025.9.1-cp310-cp310-win_arm64.whl", hash = "sha256:a874a61bb580d48642ffd338570ee24ab13fa023779190513fcacad104a6e251"}, + {file = "regex-2025.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e5bcf112b09bfd3646e4db6bf2e598534a17d502b0c01ea6550ba4eca780c5e6"}, + {file = "regex-2025.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:67a0295a3c31d675a9ee0238d20238ff10a9a2fdb7a1323c798fc7029578b15c"}, + {file = "regex-2025.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea8267fbadc7d4bd7c1301a50e85c2ff0de293ff9452a1a9f8d82c6cafe38179"}, + {file = "regex-2025.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aeff21de7214d15e928fb5ce757f9495214367ba62875100d4c18d293750cc1"}, + {file = "regex-2025.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d89f1bbbbbc0885e1c230f7770d5e98f4f00b0ee85688c871d10df8b184a6323"}, + {file = "regex-2025.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca3affe8ddea498ba9d294ab05f5f2d3b5ad5d515bc0d4a9016dd592a03afe52"}, + {file = "regex-2025.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91892a7a9f0a980e4c2c85dd19bc14de2b219a3a8867c4b5664b9f972dcc0c78"}, + {file = "regex-2025.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e1cb40406f4ae862710615f9f636c1e030fd6e6abe0e0f65f6a695a2721440c6"}, + {file = "regex-2025.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94f6cff6f7e2149c7e6499a6ecd4695379eeda8ccbccb9726e8149f2fe382e92"}, + {file = "regex-2025.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6c0226fb322b82709e78c49cc33484206647f8a39954d7e9de1567f5399becd0"}, + {file = "regex-2025.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a12f59c7c380b4fcf7516e9cbb126f95b7a9518902bcf4a852423ff1dcd03e6a"}, + {file = "regex-2025.9.1-cp311-cp311-win32.whl", hash = "sha256:49865e78d147a7a4f143064488da5d549be6bfc3f2579e5044cac61f5c92edd4"}, + {file = "regex-2025.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:d34b901f6f2f02ef60f4ad3855d3a02378c65b094efc4b80388a3aeb700a5de7"}, + {file = "regex-2025.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:47d7c2dab7e0b95b95fd580087b6ae196039d62306a592fa4e162e49004b6299"}, + {file = "regex-2025.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:84a25164bd8dcfa9f11c53f561ae9766e506e580b70279d05a7946510bdd6f6a"}, + {file = "regex-2025.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:645e88a73861c64c1af558dd12294fb4e67b5c1eae0096a60d7d8a2143a611c7"}, + {file = "regex-2025.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10a450cba5cd5409526ee1d4449f42aad38dd83ac6948cbd6d7f71ca7018f7db"}, + {file = "regex-2025.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9dc5991592933a4192c166eeb67b29d9234f9c86344481173d1bc52f73a7104"}, + {file = "regex-2025.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a32291add816961aab472f4fad344c92871a2ee33c6c219b6598e98c1f0108f2"}, + {file = "regex-2025.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:588c161a68a383478e27442a678e3b197b13c5ba51dbba40c1ccb8c4c7bee9e9"}, + {file = "regex-2025.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47829ffaf652f30d579534da9085fe30c171fa2a6744a93d52ef7195dc38218b"}, + {file = "regex-2025.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e978e5a35b293ea43f140c92a3269b6ab13fe0a2bf8a881f7ac740f5a6ade85"}, + {file = "regex-2025.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf09903e72411f4bf3ac1eddd624ecfd423f14b2e4bf1c8b547b72f248b7bf7"}, + {file = "regex-2025.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d016b0f77be63e49613c9e26aaf4a242f196cd3d7a4f15898f5f0ab55c9b24d2"}, + {file = "regex-2025.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:656563e620de6908cd1c9d4f7b9e0777e3341ca7db9d4383bcaa44709c90281e"}, + {file = "regex-2025.9.1-cp312-cp312-win32.whl", hash = "sha256:df33f4ef07b68f7ab637b1dbd70accbf42ef0021c201660656601e8a9835de45"}, + {file = "regex-2025.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5aba22dfbc60cda7c0853516104724dc904caa2db55f2c3e6e984eb858d3edf3"}, + {file = "regex-2025.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:ec1efb4c25e1849c2685fa95da44bfde1b28c62d356f9c8d861d4dad89ed56e9"}, + {file = "regex-2025.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bc6834727d1b98d710a63e6c823edf6ffbf5792eba35d3fa119531349d4142ef"}, + {file = "regex-2025.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c3dc05b6d579875719bccc5f3037b4dc80433d64e94681a0061845bd8863c025"}, + {file = "regex-2025.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22213527df4c985ec4a729b055a8306272d41d2f45908d7bacb79be0fa7a75ad"}, + {file = "regex-2025.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3f6e3c5a5a1adc3f7ea1b5aec89abfc2f4fbfba55dafb4343cd1d084f715b2"}, + {file = "regex-2025.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcb89c02a0d6c2bec9b0bb2d8c78782699afe8434493bfa6b4021cc51503f249"}, + {file = "regex-2025.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0e2f95413eb0c651cd1516a670036315b91b71767af83bc8525350d4375ccba"}, + {file = "regex-2025.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a41dc039e1c97d3c2ed3e26523f748e58c4de3ea7a31f95e1cf9ff973fff5a"}, + {file = "regex-2025.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f0b4258b161094f66857a26ee938d3fe7b8a5063861e44571215c44fbf0e5df"}, + {file = "regex-2025.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bf70e18ac390e6977ea7e56f921768002cb0fa359c4199606c7219854ae332e0"}, + {file = "regex-2025.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b84036511e1d2bb0a4ff1aec26951caa2dea8772b223c9e8a19ed8885b32dbac"}, + {file = "regex-2025.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c2e05dcdfe224047f2a59e70408274c325d019aad96227ab959403ba7d58d2d7"}, + {file = "regex-2025.9.1-cp313-cp313-win32.whl", hash = "sha256:3b9a62107a7441b81ca98261808fed30ae36ba06c8b7ee435308806bd53c1ed8"}, + {file = "regex-2025.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:b38afecc10c177eb34cfae68d669d5161880849ba70c05cbfbe409f08cc939d7"}, + {file = "regex-2025.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:ec329890ad5e7ed9fc292858554d28d58d56bf62cf964faf0aa57964b21155a0"}, + {file = "regex-2025.9.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:72fb7a016467d364546f22b5ae86c45680a4e0de6b2a6f67441d22172ff641f1"}, + {file = "regex-2025.9.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c9527fa74eba53f98ad86be2ba003b3ebe97e94b6eb2b916b31b5f055622ef03"}, + {file = "regex-2025.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c905d925d194c83a63f92422af7544ec188301451b292c8b487f0543726107ca"}, + {file = "regex-2025.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74df7c74a63adcad314426b1f4ea6054a5ab25d05b0244f0c07ff9ce640fa597"}, + {file = "regex-2025.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f6e935e98ea48c7a2e8be44494de337b57a204470e7f9c9c42f912c414cd6f5"}, + {file = "regex-2025.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4a62d033cd9ebefc7c5e466731a508dfabee827d80b13f455de68a50d3c2543d"}, + {file = "regex-2025.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef971ebf2b93bdc88d8337238be4dfb851cc97ed6808eb04870ef67589415171"}, + {file = "regex-2025.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d936a1db208bdca0eca1f2bb2c1ba1d8370b226785c1e6db76e32a228ffd0ad5"}, + {file = "regex-2025.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7e786d9e4469698fc63815b8de08a89165a0aa851720eb99f5e0ea9d51dd2b6a"}, + {file = "regex-2025.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6b81d7dbc5466ad2c57ce3a0ddb717858fe1a29535c8866f8514d785fdb9fc5b"}, + {file = "regex-2025.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4890e184a6feb0ef195338a6ce68906a8903a0f2eb7e0ab727dbc0a3156273"}, + {file = "regex-2025.9.1-cp314-cp314-win32.whl", hash = "sha256:34679a86230e46164c9e0396b56cab13c0505972343880b9e705083cc5b8ec86"}, + {file = "regex-2025.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:a1196e530a6bfa5f4bde029ac5b0295a6ecfaaffbfffede4bbaf4061d9455b70"}, + {file = "regex-2025.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:f46d525934871ea772930e997d577d48c6983e50f206ff7b66d4ac5f8941e993"}, + {file = "regex-2025.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a13d20007dce3c4b00af5d84f6c191ed1c0f70928c6d9b6cd7b8d2f125df7f46"}, + {file = "regex-2025.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d6b046b0a01cb713fd53ef36cb59db4b0062b343db28e83b52ac6aa01ee5b368"}, + {file = "regex-2025.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0fa9a7477288717f42dbd02ff5d13057549e9a8cdb81f224c313154cc10bab52"}, + {file = "regex-2025.9.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2b3ad150c6bc01a8cd5030040675060e2adbe6cbc50aadc4da42c6d32ec266e"}, + {file = "regex-2025.9.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aa88d5a82dfe80deaf04e8c39c8b0ad166d5d527097eb9431cb932c44bf88715"}, + {file = "regex-2025.9.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f1dae2cf6c2dbc6fd2526653692c144721b3cf3f769d2a3c3aa44d0f38b9a58"}, + {file = "regex-2025.9.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff62a3022914fc19adaa76b65e03cf62bc67ea16326cbbeb170d280710a7d719"}, + {file = "regex-2025.9.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a34ef82216189d823bc82f614d1031cb0b919abef27cecfd7b07d1e9a8bdeeb4"}, + {file = "regex-2025.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d40e6b49daae9ebbd7fa4e600697372cba85b826592408600068e83a3c47211"}, + {file = "regex-2025.9.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0aeb0fe80331059c152a002142699a89bf3e44352aee28261315df0c9874759b"}, + {file = "regex-2025.9.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a90014d29cb3098403d82a879105d1418edbbdf948540297435ea6e377023ea7"}, + {file = "regex-2025.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6ff623271e0b0cc5a95b802666bbd70f17ddd641582d65b10fb260cc0c003529"}, + {file = "regex-2025.9.1-cp39-cp39-win32.whl", hash = "sha256:d161bfdeabe236290adfd8c7588da7f835d67e9e7bf2945f1e9e120622839ba6"}, + {file = "regex-2025.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:43ebc77a7dfe36661192afd8d7df5e8be81ec32d2ad0c65b536f66ebfec3dece"}, + {file = "regex-2025.9.1-cp39-cp39-win_arm64.whl", hash = "sha256:5d74b557cf5554001a869cda60b9a619be307df4d10155894aeaad3ee67c9899"}, + {file = "regex-2025.9.1.tar.gz", hash = "sha256:88ac07b38d20b54d79e704e38aa3bd2c0f8027432164226bdee201a1c0c9c9ff"}, +] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "safetensors" +version = "0.6.2" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "safetensors-0.6.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9c85ede8ec58f120bad982ec47746981e210492a6db876882aa021446af8ffba"}, + {file = "safetensors-0.6.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6675cf4b39c98dbd7d940598028f3742e0375a6b4d4277e76beb0c35f4b843b"}, + {file = "safetensors-0.6.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d2d2b3ce1e2509c68932ca03ab8f20570920cd9754b05063d4368ee52833ecd"}, + {file = "safetensors-0.6.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:93de35a18f46b0f5a6a1f9e26d91b442094f2df02e9fd7acf224cfec4238821a"}, + {file = "safetensors-0.6.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89a89b505f335640f9120fac65ddeb83e40f1fd081cb8ed88b505bdccec8d0a1"}, + {file = "safetensors-0.6.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4d0d0b937e04bdf2ae6f70cd3ad51328635fe0e6214aa1fc811f3b576b3bda"}, + {file = "safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8045db2c872db8f4cbe3faa0495932d89c38c899c603f21e9b6486951a5ecb8f"}, + {file = "safetensors-0.6.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:81e67e8bab9878bb568cffbc5f5e655adb38d2418351dc0859ccac158f753e19"}, + {file = "safetensors-0.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0e4d029ab0a0e0e4fdf142b194514695b1d7d3735503ba700cf36d0fc7136ce"}, + {file = "safetensors-0.6.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fa48268185c52bfe8771e46325a1e21d317207bcabcb72e65c6e28e9ffeb29c7"}, + {file = "safetensors-0.6.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d83c20c12c2d2f465997c51b7ecb00e407e5f94d7dec3ea0cc11d86f60d3fde5"}, + {file = "safetensors-0.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d944cea65fad0ead848b6ec2c37cc0b197194bec228f8020054742190e9312ac"}, + {file = "safetensors-0.6.2-cp38-abi3-win32.whl", hash = "sha256:cab75ca7c064d3911411461151cb69380c9225798a20e712b102edda2542ddb1"}, + {file = "safetensors-0.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:c7b214870df923cbc1593c3faee16bec59ea462758699bd3fee399d00aac072c"}, + {file = "safetensors-0.6.2.tar.gz", hash = "sha256:43ff2aa0e6fa2dc3ea5524ac7ad93a9839256b8703761e76e2d0b2a3fa4f15d9"}, +] + +[package.extras] +all = ["safetensors[jax]", "safetensors[numpy]", "safetensors[paddlepaddle]", "safetensors[pinned-tf]", "safetensors[quality]", "safetensors[testing]", "safetensors[torch]"] +dev = ["safetensors[all]"] +jax = ["flax (>=0.6.3)", "jax (>=0.3.25)", "jaxlib (>=0.3.25)", "safetensors[numpy]"] +mlx = ["mlx (>=0.0.9)"] +numpy = ["numpy (>=1.21.6)"] +paddlepaddle = ["paddlepaddle (>=2.4.1)", "safetensors[numpy]"] +pinned-tf = ["safetensors[numpy]", "tensorflow (==2.18.0)"] +quality = ["ruff"] +tensorflow = ["safetensors[numpy]", "tensorflow (>=2.11.0)"] +testing = ["h5py (>=3.7.0)", "huggingface-hub (>=0.12.1)", "hypothesis (>=6.70.2)", "pytest (>=7.2.0)", "pytest-benchmark (>=4.0.0)", "safetensors[numpy]", "setuptools-rust (>=1.5.2)"] +testingfree = ["huggingface-hub (>=0.12.1)", "hypothesis (>=6.70.2)", "pytest (>=7.2.0)", "pytest-benchmark (>=4.0.0)", "safetensors[numpy]", "setuptools-rust (>=1.5.2)"] +torch = ["safetensors[numpy]", "torch (>=1.10)"] + +[[package]] +name = "scikit-learn" +version = "1.7.1" +description = "A set of python modules for machine learning and data mining" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "scikit_learn-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:406204dd4004f0517f0b23cf4b28c6245cbd51ab1b6b78153bc784def214946d"}, + {file = "scikit_learn-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:16af2e44164f05d04337fd1fc3ae7c4ea61fd9b0d527e22665346336920fe0e1"}, + {file = "scikit_learn-1.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2f2e78e56a40c7587dea9a28dc4a49500fa2ead366869418c66f0fd75b80885c"}, + {file = "scikit_learn-1.7.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62b76ad408a821475b43b7bb90a9b1c9a4d8d125d505c2df0539f06d6e631b1"}, + {file = "scikit_learn-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:9963b065677a4ce295e8ccdee80a1dd62b37249e667095039adcd5bce6e90deb"}, + {file = "scikit_learn-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90c8494ea23e24c0fb371afc474618c1019dc152ce4a10e4607e62196113851b"}, + {file = "scikit_learn-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:bb870c0daf3bf3be145ec51df8ac84720d9972170786601039f024bf6d61a518"}, + {file = "scikit_learn-1.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40daccd1b5623f39e8943ab39735cadf0bdce80e67cdca2adcb5426e987320a8"}, + {file = "scikit_learn-1.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30d1f413cfc0aa5a99132a554f1d80517563c34a9d3e7c118fde2d273c6fe0f7"}, + {file = "scikit_learn-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:c711d652829a1805a95d7fe96654604a8f16eab5a9e9ad87b3e60173415cb650"}, + {file = "scikit_learn-1.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3cee419b49b5bbae8796ecd690f97aa412ef1674410c23fc3257c6b8b85b8087"}, + {file = "scikit_learn-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2fd8b8d35817b0d9ebf0b576f7d5ffbbabdb55536b0655a8aaae629d7ffd2e1f"}, + {file = "scikit_learn-1.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:588410fa19a96a69763202f1d6b7b91d5d7a5d73be36e189bc6396bfb355bd87"}, + {file = "scikit_learn-1.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3142f0abe1ad1d1c31a2ae987621e41f6b578144a911ff4ac94781a583adad7"}, + {file = "scikit_learn-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ddd9092c1bd469acab337d87930067c87eac6bd544f8d5027430983f1e1ae88"}, + {file = "scikit_learn-1.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7839687fa46d02e01035ad775982f2470be2668e13ddd151f0f55a5bf123bae"}, + {file = "scikit_learn-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a10f276639195a96c86aa572ee0698ad64ee939a7b042060b98bd1930c261d10"}, + {file = "scikit_learn-1.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13679981fdaebc10cc4c13c43344416a86fcbc61449cb3e6517e1df9d12c8309"}, + {file = "scikit_learn-1.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f1262883c6a63f067a980a8cdd2d2e7f2513dddcef6a9eaada6416a7a7cbe43"}, + {file = "scikit_learn-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca6d31fb10e04d50bfd2b50d66744729dbb512d4efd0223b864e2fdbfc4cee11"}, + {file = "scikit_learn-1.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:781674d096303cfe3d351ae6963ff7c958db61cde3421cd490e3a5a58f2a94ae"}, + {file = "scikit_learn-1.7.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:10679f7f125fe7ecd5fad37dd1aa2daae7e3ad8df7f3eefa08901b8254b3e12c"}, + {file = "scikit_learn-1.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f812729e38c8cb37f760dce71a9b83ccfb04f59b3dca7c6079dcdc60544fa9e"}, + {file = "scikit_learn-1.7.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88e1a20131cf741b84b89567e1717f27a2ced228e0f29103426102bc2e3b8ef7"}, + {file = "scikit_learn-1.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b1bd1d919210b6a10b7554b717c9000b5485aa95a1d0f177ae0d7ee8ec750da5"}, + {file = "scikit_learn-1.7.1.tar.gz", hash = "sha256:24b3f1e976a4665aa74ee0fcaac2b8fccc6ae77c8e07ab25da3ba6d3292b9802"}, +] + +[package.dependencies] +joblib = ">=1.2.0" +numpy = ">=1.22.0" +scipy = ">=1.8.0" +threadpoolctl = ">=3.1.0" + +[package.extras] +benchmark = ["matplotlib (>=3.5.0)", "memory_profiler (>=0.57.0)", "pandas (>=1.4.0)"] +build = ["cython (>=3.0.10)", "meson-python (>=0.17.1)", "numpy (>=1.22.0)", "scipy (>=1.8.0)"] +docs = ["Pillow (>=8.4.0)", "matplotlib (>=3.5.0)", "memory_profiler (>=0.57.0)", "numpydoc (>=1.2.0)", "pandas (>=1.4.0)", "plotly (>=5.14.0)", "polars (>=0.20.30)", "pooch (>=1.6.0)", "pydata-sphinx-theme (>=0.15.3)", "scikit-image (>=0.19.0)", "seaborn (>=0.9.0)", "sphinx (>=7.3.7)", "sphinx-copybutton (>=0.5.2)", "sphinx-design (>=0.5.0)", "sphinx-design (>=0.6.0)", "sphinx-gallery (>=0.17.1)", "sphinx-prompt (>=1.4.0)", "sphinx-remove-toctrees (>=1.0.0.post1)", "sphinxcontrib-sass (>=0.3.4)", "sphinxext-opengraph (>=0.9.1)", "towncrier (>=24.8.0)"] +examples = ["matplotlib (>=3.5.0)", "pandas (>=1.4.0)", "plotly (>=5.14.0)", "pooch (>=1.6.0)", "scikit-image (>=0.19.0)", "seaborn (>=0.9.0)"] +install = ["joblib (>=1.2.0)", "numpy (>=1.22.0)", "scipy (>=1.8.0)", "threadpoolctl (>=3.1.0)"] +maintenance = ["conda-lock (==3.0.1)"] +tests = ["matplotlib (>=3.5.0)", "mypy (>=1.15)", "numpydoc (>=1.2.0)", "pandas (>=1.4.0)", "polars (>=0.20.30)", "pooch (>=1.6.0)", "pyamg (>=4.2.1)", "pyarrow (>=12.0.0)", "pytest (>=7.1.2)", "pytest-cov (>=2.9.0)", "ruff (>=0.11.7)", "scikit-image (>=0.19.0)"] + +[[package]] +name = "scipy" +version = "1.16.1" +description = "Fundamental algorithms for scientific computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "scipy-1.16.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:c033fa32bab91dc98ca59d0cf23bb876454e2bb02cbe592d5023138778f70030"}, + {file = "scipy-1.16.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6e5c2f74e5df33479b5cd4e97a9104c511518fbd979aa9b8f6aec18b2e9ecae7"}, + {file = "scipy-1.16.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0a55ffe0ba0f59666e90951971a884d1ff6f4ec3275a48f472cfb64175570f77"}, + {file = "scipy-1.16.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f8a5d6cd147acecc2603fbd382fed6c46f474cccfcf69ea32582e033fb54dcfe"}, + {file = "scipy-1.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb18899127278058bcc09e7b9966d41a5a43740b5bb8dcba401bd983f82e885b"}, + {file = "scipy-1.16.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adccd93a2fa937a27aae826d33e3bfa5edf9aa672376a4852d23a7cd67a2e5b7"}, + {file = "scipy-1.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:18aca1646a29ee9a0625a1be5637fa798d4d81fdf426481f06d69af828f16958"}, + {file = "scipy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d85495cef541729a70cdddbbf3e6b903421bc1af3e8e3a9a72a06751f33b7c39"}, + {file = "scipy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:226652fca853008119c03a8ce71ffe1b3f6d2844cc1686e8f9806edafae68596"}, + {file = "scipy-1.16.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81b433bbeaf35728dad619afc002db9b189e45eebe2cd676effe1fb93fef2b9c"}, + {file = "scipy-1.16.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:886cc81fdb4c6903a3bb0464047c25a6d1016fef77bb97949817d0c0d79f9e04"}, + {file = "scipy-1.16.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:15240c3aac087a522b4eaedb09f0ad061753c5eebf1ea430859e5bf8640d5919"}, + {file = "scipy-1.16.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f81a25805f3659b48126b5053d9e823d3215e4a63730b5e1671852a1705921"}, + {file = "scipy-1.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c62eea7f607f122069b9bad3f99489ddca1a5173bef8a0c75555d7488b6f725"}, + {file = "scipy-1.16.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f965bbf3235b01c776115ab18f092a95aa74c271a52577bcb0563e85738fd618"}, + {file = "scipy-1.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f006e323874ffd0b0b816d8c6a8e7f9a73d55ab3b8c3f72b752b226d0e3ac83d"}, + {file = "scipy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8fd15fc5085ab4cca74cb91fe0a4263b1f32e4420761ddae531ad60934c2119"}, + {file = "scipy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7b8013c6c066609577d910d1a2a077021727af07b6fab0ee22c2f901f22352a"}, + {file = "scipy-1.16.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5451606823a5e73dfa621a89948096c6528e2896e40b39248295d3a0138d594f"}, + {file = "scipy-1.16.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:89728678c5ca5abd610aee148c199ac1afb16e19844401ca97d43dc548a354eb"}, + {file = "scipy-1.16.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e756d688cb03fd07de0fffad475649b03cb89bee696c98ce508b17c11a03f95c"}, + {file = "scipy-1.16.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5aa2687b9935da3ed89c5dbed5234576589dd28d0bf7cd237501ccfbdf1ad608"}, + {file = "scipy-1.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0851f6a1e537fe9399f35986897e395a1aa61c574b178c0d456be5b1a0f5ca1f"}, + {file = "scipy-1.16.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fedc2cbd1baed37474b1924c331b97bdff611d762c196fac1a9b71e67b813b1b"}, + {file = "scipy-1.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ef500e72f9623a6735769e4b93e9dcb158d40752cdbb077f305487e3e2d1f45"}, + {file = "scipy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:978d8311674b05a8f7ff2ea6c6bce5d8b45a0cb09d4c5793e0318f448613ea65"}, + {file = "scipy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:81929ed0fa7a5713fcdd8b2e6f73697d3b4c4816d090dd34ff937c20fa90e8ab"}, + {file = "scipy-1.16.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:bcc12db731858abda693cecdb3bdc9e6d4bd200213f49d224fe22df82687bdd6"}, + {file = "scipy-1.16.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:744d977daa4becb9fc59135e75c069f8d301a87d64f88f1e602a9ecf51e77b27"}, + {file = "scipy-1.16.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:dc54f76ac18073bcecffb98d93f03ed6b81a92ef91b5d3b135dcc81d55a724c7"}, + {file = "scipy-1.16.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:367d567ee9fc1e9e2047d31f39d9d6a7a04e0710c86e701e053f237d14a9b4f6"}, + {file = "scipy-1.16.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4cf5785e44e19dcd32a0e4807555e1e9a9b8d475c6afff3d21c3c543a6aa84f4"}, + {file = "scipy-1.16.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3d0b80fb26d3e13a794c71d4b837e2a589d839fd574a6bbb4ee1288c213ad4a3"}, + {file = "scipy-1.16.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8503517c44c18d1030d666cb70aaac1cc8913608816e06742498833b128488b7"}, + {file = "scipy-1.16.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:30cc4bb81c41831ecfd6dc450baf48ffd80ef5aed0f5cf3ea775740e80f16ecc"}, + {file = "scipy-1.16.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c24fa02f7ed23ae514460a22c57eca8f530dbfa50b1cfdbf4f37c05b5309cc39"}, + {file = "scipy-1.16.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:796a5a9ad36fa3a782375db8f4241ab02a091308eb079746bc0f874c9b998318"}, + {file = "scipy-1.16.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:3ea0733a2ff73fd6fdc5fecca54ee9b459f4d74f00b99aced7d9a3adb43fb1cc"}, + {file = "scipy-1.16.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:85764fb15a2ad994e708258bb4ed8290d1305c62a4e1ef07c414356a24fcfbf8"}, + {file = "scipy-1.16.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ca66d980469cb623b1759bdd6e9fd97d4e33a9fad5b33771ced24d0cb24df67e"}, + {file = "scipy-1.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7cc1ffcc230f568549fc56670bcf3df1884c30bd652c5da8138199c8c76dae0"}, + {file = "scipy-1.16.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ddfb1e8d0b540cb4ee9c53fc3dea3186f97711248fb94b4142a1b27178d8b4b"}, + {file = "scipy-1.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4dc0e7be79e95d8ba3435d193e0d8ce372f47f774cffd882f88ea4e1e1ddc731"}, + {file = "scipy-1.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f23634f9e5adb51b2a77766dac217063e764337fbc816aa8ad9aaebcd4397fd3"}, + {file = "scipy-1.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:57d75524cb1c5a374958a2eae3d84e1929bb971204cc9d52213fb8589183fc19"}, + {file = "scipy-1.16.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:d8da7c3dd67bcd93f15618938f43ed0995982eb38973023d46d4646c4283ad65"}, + {file = "scipy-1.16.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:cc1d2f2fd48ba1e0620554fe5bc44d3e8f5d4185c8c109c7fbdf5af2792cfad2"}, + {file = "scipy-1.16.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:21a611ced9275cb861bacadbada0b8c0623bc00b05b09eb97f23b370fc2ae56d"}, + {file = "scipy-1.16.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dfbb25dffc4c3dd9371d8ab456ca81beeaf6f9e1c2119f179392f0dc1ab7695"}, + {file = "scipy-1.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0ebb7204f063fad87fc0a0e4ff4a2ff40b2a226e4ba1b7e34bf4b79bf97cd86"}, + {file = "scipy-1.16.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1b9e5962656f2734c2b285a8745358ecb4e4efbadd00208c80a389227ec61ff"}, + {file = "scipy-1.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e1a106f8c023d57a2a903e771228bf5c5b27b5d692088f457acacd3b54511e4"}, + {file = "scipy-1.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:709559a1db68a9abc3b2c8672c4badf1614f3b440b3ab326d86a5c0491eafae3"}, + {file = "scipy-1.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c0c804d60492a0aad7f5b2bb1862f4548b990049e27e828391ff2bf6f7199998"}, + {file = "scipy-1.16.1.tar.gz", hash = "sha256:44c76f9e8b6e8e488a586190ab38016e4ed2f8a038af7cd3defa903c0a2238b3"}, +] + +[package.dependencies] +numpy = ">=1.25.2,<2.6" + +[package.extras] +dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] +doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] +test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] + +[[package]] +name = "selenium" +version = "4.35.0" +description = "Official Python bindings for Selenium WebDriver" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "selenium-4.35.0-py3-none-any.whl", hash = "sha256:90bb6c6091fa55805785cf1660fa1e2176220475ccdb466190f654ef8eef6114"}, + {file = "selenium-4.35.0.tar.gz", hash = "sha256:83937a538afb40ef01e384c1405c0863fa184c26c759d34a1ebbe7b925d3481c"}, +] + +[package.dependencies] +certifi = ">=2025.6.15" +trio = ">=0.30.0,<0.31.0" +trio-websocket = ">=0.12.2,<0.13.0" +typing_extensions = ">=4.14.0,<4.15.0" +urllib3 = {version = ">=2.5.0,<3.0", extras = ["socks"]} +websocket-client = ">=1.8.0,<1.9.0" + +[[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"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" or python_version >= \"3.12\"" +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 = "sniffio" version = "1.3.1" @@ -404,6 +1952,30 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "soupsieve" +version = "2.8" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"}, + {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"}, +] + [[package]] name = "starlette" version = "0.47.2" @@ -423,6 +1995,291 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "sympy" +version = "1.14.0" +description = "Computer algebra system (CAS) in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, + {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, +] + +[package.dependencies] +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +description = "threadpoolctl" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb"}, + {file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"}, +] + +[[package]] +name = "tokenizers" +version = "0.22.0" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tokenizers-0.22.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:eaa9620122a3fb99b943f864af95ed14c8dfc0f47afa3b404ac8c16b3f2bb484"}, + {file = "tokenizers-0.22.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:71784b9ab5bf0ff3075bceeb198149d2c5e068549c0d18fe32d06ba0deb63f79"}, + {file = "tokenizers-0.22.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec5b71f668a8076802b0241a42387d48289f25435b86b769ae1837cad4172a17"}, + {file = "tokenizers-0.22.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea8562fa7498850d02a16178105b58803ea825b50dc9094d60549a7ed63654bb"}, + {file = "tokenizers-0.22.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4136e1558a9ef2e2f1de1555dcd573e1cbc4a320c1a06c4107a3d46dc8ac6e4b"}, + {file = "tokenizers-0.22.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf5954de3962a5fd9781dc12048d24a1a6f1f5df038c6e95db328cd22964206"}, + {file = "tokenizers-0.22.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8337ca75d0731fc4860e6204cc24bb36a67d9736142aa06ed320943b50b1e7ed"}, + {file = "tokenizers-0.22.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a89264e26f63c449d8cded9061adea7b5de53ba2346fc7e87311f7e4117c1cc8"}, + {file = "tokenizers-0.22.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:790bad50a1b59d4c21592f9c3cf5e5cf9c3c7ce7e1a23a739f13e01fb1be377a"}, + {file = "tokenizers-0.22.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:76cf6757c73a10ef10bf06fa937c0ec7393d90432f543f49adc8cab3fb6f26cb"}, + {file = "tokenizers-0.22.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1626cb186e143720c62c6c6b5371e62bbc10af60481388c0da89bc903f37ea0c"}, + {file = "tokenizers-0.22.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da589a61cbfea18ae267723d6b029b84598dc8ca78db9951d8f5beff72d8507c"}, + {file = "tokenizers-0.22.0-cp39-abi3-win32.whl", hash = "sha256:dbf9d6851bddae3e046fedfb166f47743c1c7bd11c640f0691dd35ef0bcad3be"}, + {file = "tokenizers-0.22.0-cp39-abi3-win_amd64.whl", hash = "sha256:c78174859eeaee96021f248a56c801e36bfb6bd5b067f2e95aa82445ca324f00"}, + {file = "tokenizers-0.22.0.tar.gz", hash = "sha256:2e33b98525be8453f355927f3cab312c36cd3e44f4d7e9e97da2fa94d0a49dcb"}, +] + +[package.dependencies] +huggingface-hub = ">=0.16.4,<1.0" + +[package.extras] +dev = ["tokenizers[testing]"] +docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] +testing = ["black (==22.3)", "datasets", "numpy", "pytest", "pytest-asyncio", "requests", "ruff"] + +[[package]] +name = "torch" +version = "2.8.0" +description = "Tensors and Dynamic neural networks in Python with strong GPU acceleration" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "torch-2.8.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:0be92c08b44009d4131d1ff7a8060d10bafdb7ddcb7359ef8d8c5169007ea905"}, + {file = "torch-2.8.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:89aa9ee820bb39d4d72b794345cccef106b574508dd17dbec457949678c76011"}, + {file = "torch-2.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8e5bf982e87e2b59d932769938b698858c64cc53753894be25629bdf5cf2f46"}, + {file = "torch-2.8.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:a3f16a58a9a800f589b26d47ee15aca3acf065546137fc2af039876135f4c760"}, + {file = "torch-2.8.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:220a06fd7af8b653c35d359dfe1aaf32f65aa85befa342629f716acb134b9710"}, + {file = "torch-2.8.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c12fa219f51a933d5f80eeb3a7a5d0cbe9168c0a14bbb4055f1979431660879b"}, + {file = "torch-2.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c7ef765e27551b2fbfc0f41bcf270e1292d9bf79f8e0724848b1682be6e80aa"}, + {file = "torch-2.8.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:5ae0524688fb6707c57a530c2325e13bb0090b745ba7b4a2cd6a3ce262572916"}, + {file = "torch-2.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e2fab4153768d433f8ed9279c8133a114a034a61e77a3a104dcdf54388838705"}, + {file = "torch-2.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2aca0939fb7e4d842561febbd4ffda67a8e958ff725c1c27e244e85e982173c"}, + {file = "torch-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:2f4ac52f0130275d7517b03a33d2493bab3693c83dcfadf4f81688ea82147d2e"}, + {file = "torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:619c2869db3ada2c0105487ba21b5008defcc472d23f8b80ed91ac4a380283b0"}, + {file = "torch-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b2f96814e0345f5a5aed9bf9734efa913678ed19caf6dc2cddb7930672d6128"}, + {file = "torch-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:65616ca8ec6f43245e1f5f296603e33923f4c30f93d65e103d9e50c25b35150b"}, + {file = "torch-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:659df54119ae03e83a800addc125856effda88b016dfc54d9f65215c3975be16"}, + {file = "torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:1a62a1ec4b0498930e2543535cf70b1bef8c777713de7ceb84cd79115f553767"}, + {file = "torch-2.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:83c13411a26fac3d101fe8035a6b0476ae606deb8688e904e796a3534c197def"}, + {file = "torch-2.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8f0a9d617a66509ded240add3754e462430a6c1fc5589f86c17b433dd808f97a"}, + {file = "torch-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a7242b86f42be98ac674b88a4988643b9bc6145437ec8f048fea23f72feb5eca"}, + {file = "torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7b677e17f5a3e69fdef7eb3b9da72622f8d322692930297e4ccb52fefc6c8211"}, + {file = "torch-2.8.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:da6afa31c13b669d4ba49d8a2169f0db2c3ec6bec4af898aa714f401d4c38904"}, + {file = "torch-2.8.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:06fcee8000e5c62a9f3e52a688b9c5abb7c6228d0e56e3452983416025c41381"}, + {file = "torch-2.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:5128fe752a355d9308e56af1ad28b15266fe2da5948660fad44de9e3a9e36e8c"}, + {file = "torch-2.8.0-cp39-none-macosx_11_0_arm64.whl", hash = "sha256:e9f071f5b52a9f6970dc8a919694b27a91ae9dc08898b2b988abbef5eddfd1ae"}, +] + +[package.dependencies] +filelock = "*" +fsspec = "*" +jinja2 = "*" +networkx = "*" +nvidia-cublas-cu12 = {version = "12.8.4.1", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-cupti-cu12 = {version = "12.8.90", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-nvrtc-cu12 = {version = "12.8.93", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-runtime-cu12 = {version = "12.8.90", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cudnn-cu12 = {version = "9.10.2.21", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cufft-cu12 = {version = "11.3.3.83", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cufile-cu12 = {version = "1.13.1.3", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-curand-cu12 = {version = "10.3.9.90", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusolver-cu12 = {version = "11.7.3.90", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusparse-cu12 = {version = "12.5.8.93", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusparselt-cu12 = {version = "0.7.1", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nccl-cu12 = {version = "2.27.3", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nvjitlink-cu12 = {version = "12.8.93", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nvtx-cu12 = {version = "12.8.90", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +setuptools = {version = "*", markers = "python_version >= \"3.12\""} +sympy = ">=1.13.3" +triton = {version = "3.4.0", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +typing-extensions = ">=4.10.0" + +[package.extras] +opt-einsum = ["opt-einsum (>=3.3)"] +optree = ["optree (>=0.13.0)"] +pyyaml = ["pyyaml"] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "transformers" +version = "4.56.0" +description = "State-of-the-art Machine Learning for JAX, PyTorch and TensorFlow" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "transformers-4.56.0-py3-none-any.whl", hash = "sha256:bacf539c38dd850690856881c4974321af93a22f2ee96bcc994741a2121d8e71"}, + {file = "transformers-4.56.0.tar.gz", hash = "sha256:6ca9c3f38aa4da93ebf877db7156368c1c188c7465f09dbe70951e7622e987fa"}, +] + +[package.dependencies] +filelock = "*" +huggingface-hub = ">=0.34.0,<1.0" +numpy = ">=1.17" +packaging = ">=20.0" +pyyaml = ">=5.1" +regex = "!=2019.12.17" +requests = "*" +safetensors = ">=0.4.3" +tokenizers = ">=0.22.0,<=0.23.0" +tqdm = ">=4.27" + +[package.extras] +accelerate = ["accelerate (>=0.26.0)"] +all = ["Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av", "codecarbon (>=2.8.1)", "flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "jinja2 (>=3.1.0)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "kernels (>=0.6.1,<=0.9)", "librosa", "mistral-common[opencv] (>=1.6.3)", "num2words", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "phonemizer", "protobuf", "pyctcdecode (>=0.4.0)", "ray[tune] (>=2.7.0)", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timm (!=1.0.18,<=1.0.19)", "tokenizers (>=0.22.0,<=0.23.0)", "torch (>=2.2)", "torchaudio", "torchvision"] +audio = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +benchmark = ["optimum-benchmark (>=0.3.0)"] +chat-template = ["jinja2 (>=3.1.0)"] +codecarbon = ["codecarbon (>=2.8.1)"] +deepspeed = ["accelerate (>=0.26.0)", "deepspeed (>=0.9.3)"] +deepspeed-testing = ["GitPython (<3.1.19)", "accelerate (>=0.26.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (>=2.15.0)", "deepspeed (>=0.9.3)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "libcst", "mistral-common[opencv] (>=1.6.3)", "nltk (<=3.8.1)", "optuna", "parameterized (>=0.9)", "protobuf", "psutil", "pydantic (>=2)", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] +dev = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "av", "beautifulsoup4", "codecarbon (>=2.8.1)", "cookiecutter (==1.7.3)", "datasets (>=2.15.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "flax (>=0.4.1,<=0.7.0)", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "jinja2 (>=3.1.0)", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "kernels (>=0.6.1,<=0.9)", "libcst", "librosa", "mistral-common[opencv] (>=1.6.3)", "nltk (<=3.8.1)", "num2words", "onnxconverter-common", "optax (>=0.0.8,<=0.1.4)", "optuna", "pandas (<2.3.0)", "parameterized (>=0.9)", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic (>=2)", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "scipy (<1.13.0)", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "timm (!=1.0.18,<=1.0.19)", "tokenizers (>=0.22.0,<=0.23.0)", "torch (>=2.2)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +dev-tensorflow = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (>=2.15.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "kenlm", "keras-nlp (>=0.3.1,<0.14.0)", "libcst", "librosa", "mistral-common[opencv] (>=1.6.3)", "nltk (<=3.8.1)", "onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "pandas (<2.3.0)", "parameterized (>=0.9)", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic (>=2)", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx", "timeout-decorator", "tokenizers (>=0.22.0,<=0.23.0)", "urllib3 (<2.0.0)"] +dev-torch = ["GitPython (<3.1.19)", "Pillow (>=10.0.1,<=15.0)", "accelerate (>=0.26.0)", "beautifulsoup4", "codecarbon (>=2.8.1)", "cookiecutter (==1.7.3)", "datasets (>=2.15.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "kenlm", "kernels (>=0.6.1,<=0.9)", "libcst", "librosa", "mistral-common[opencv] (>=1.6.3)", "nltk (<=3.8.1)", "num2words", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "optuna", "pandas (<2.3.0)", "parameterized (>=0.9)", "phonemizer", "protobuf", "psutil", "pyctcdecode (>=0.4.0)", "pydantic (>=2)", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "ray[tune] (>=2.7.0)", "rhoknp (>=1.1.0,<1.3.1)", "rich", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "scikit-learn", "sentencepiece (>=0.1.91,!=0.1.92)", "sigopt", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "tensorboard", "timeout-decorator", "timm (!=1.0.18,<=1.0.19)", "tokenizers (>=0.22.0,<=0.23.0)", "torch (>=2.2)", "torchaudio", "torchvision", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)", "urllib3 (<2.0.0)"] +flax = ["flax (>=0.4.1,<=0.7.0)", "jax (>=0.4.1,<=0.4.13)", "jaxlib (>=0.4.1,<=0.4.13)", "optax (>=0.0.8,<=0.1.4)", "scipy (<1.13.0)"] +flax-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +ftfy = ["ftfy"] +hf-xet = ["hf-xet"] +hub-kernels = ["kernels (>=0.6.1,<=0.9)"] +integrations = ["kernels (>=0.6.1,<=0.9)", "optuna", "ray[tune] (>=2.7.0)", "sigopt"] +ja = ["fugashi (>=1.0)", "ipadic (>=1.0.0,<2.0)", "rhoknp (>=1.1.0,<1.3.1)", "sudachidict-core (>=20220729)", "sudachipy (>=0.6.6)", "unidic (>=1.0.2)", "unidic-lite (>=1.0.7)"] +mistral-common = ["mistral-common[opencv] (>=1.6.3)"] +modelcreation = ["cookiecutter (==1.7.3)"] +natten = ["natten (>=0.14.6,<0.15.0)"] +num2words = ["num2words"] +onnx = ["onnxconverter-common", "onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)", "tf2onnx"] +onnxruntime = ["onnxruntime (>=1.4.0)", "onnxruntime-tools (>=1.4.2)"] +open-telemetry = ["opentelemetry-api", "opentelemetry-exporter-otlp", "opentelemetry-sdk"] +optuna = ["optuna"] +quality = ["GitPython (<3.1.19)", "datasets (>=2.15.0)", "libcst", "pandas (<2.3.0)", "rich", "ruff (==0.11.2)", "urllib3 (<2.0.0)"] +ray = ["ray[tune] (>=2.7.0)"] +retrieval = ["datasets (>=2.15.0)", "faiss-cpu"] +ruff = ["ruff (==0.11.2)"] +sagemaker = ["sagemaker (>=2.31.0)"] +sentencepiece = ["protobuf", "sentencepiece (>=0.1.91,!=0.1.92)"] +serving = ["accelerate (>=0.26.0)", "fastapi", "openai (>=1.98.0)", "pydantic (>=2)", "starlette", "torch (>=2.2)", "uvicorn"] +sigopt = ["sigopt"] +sklearn = ["scikit-learn"] +speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] +testing = ["GitPython (<3.1.19)", "beautifulsoup4", "cookiecutter (==1.7.3)", "datasets (>=2.15.0)", "dill (<0.3.5)", "evaluate (>=0.2.0)", "faiss-cpu", "libcst", "mistral-common[opencv] (>=1.6.3)", "nltk (<=3.8.1)", "parameterized (>=0.9)", "psutil", "pydantic (>=2)", "pytest (>=7.2.0)", "pytest-asyncio", "pytest-order", "pytest-rerunfailures", "pytest-rich", "pytest-timeout", "pytest-xdist", "rjieba", "rouge-score (!=0.0.7,!=0.0.8,!=0.1,!=0.1.1)", "ruff (==0.11.2)", "sacrebleu (>=1.4.12,<2.0.0)", "sacremoses", "sentencepiece (>=0.1.91,!=0.1.92)", "tensorboard", "timeout-decorator"] +tf = ["keras-nlp (>=0.3.1,<0.14.0)", "onnxconverter-common", "tensorflow (>2.9,<2.16)", "tensorflow-text (<2.16)", "tf2onnx"] +tf-cpu = ["keras (>2.9,<2.16)", "keras-nlp (>=0.3.1,<0.14.0)", "onnxconverter-common", "tensorflow-cpu (>2.9,<2.16)", "tensorflow-probability (<0.24)", "tensorflow-text (<2.16)", "tf2onnx"] +tf-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)"] +tiktoken = ["blobfile", "tiktoken"] +timm = ["timm (!=1.0.18,<=1.0.19)"] +tokenizers = ["tokenizers (>=0.22.0,<=0.23.0)"] +torch = ["accelerate (>=0.26.0)", "torch (>=2.2)"] +torch-speech = ["kenlm", "librosa", "phonemizer", "pyctcdecode (>=0.4.0)", "torchaudio"] +torch-vision = ["Pillow (>=10.0.1,<=15.0)", "torchvision"] +torchhub = ["filelock", "huggingface-hub (>=0.34.0,<1.0)", "importlib-metadata", "numpy (>=1.17)", "packaging (>=20.0)", "protobuf", "regex (!=2019.12.17)", "requests", "sentencepiece (>=0.1.91,!=0.1.92)", "tokenizers (>=0.22.0,<=0.23.0)", "torch (>=2.2)", "tqdm (>=4.27)"] +video = ["av"] +vision = ["Pillow (>=10.0.1,<=15.0)"] + +[[package]] +name = "trio" +version = "0.30.0" +description = "A friendly Python library for async concurrency and I/O" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "trio-0.30.0-py3-none-any.whl", hash = "sha256:3bf4f06b8decf8d3cf00af85f40a89824669e2d033bb32469d34840edcfc22a5"}, + {file = "trio-0.30.0.tar.gz", hash = "sha256:0781c857c0c81f8f51e0089929a26b5bb63d57f927728a5586f7e36171f064df"}, +] + +[package.dependencies] +attrs = ">=23.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +idna = "*" +outcome = "*" +sniffio = ">=1.3.0" +sortedcontainers = "*" + +[[package]] +name = "trio-websocket" +version = "0.12.2" +description = "WebSocket library for Trio" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"}, + {file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"}, +] + +[package.dependencies] +outcome = ">=1.2.0" +trio = ">=0.11" +wsproto = ">=0.14" + +[[package]] +name = "triton" +version = "3.4.0" +description = "A language and compiler for custom Deep Learning operations" +optional = false +python-versions = "<3.14,>=3.9" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "triton-3.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ff2785de9bc02f500e085420273bb5cc9c9bb767584a4aa28d6e360cec70128"}, + {file = "triton-3.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b70f5e6a41e52e48cfc087436c8a28c17ff98db369447bcaff3b887a3ab4467"}, + {file = "triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04"}, + {file = "triton-3.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00be2964616f4c619193cb0d1b29a99bd4b001d7dc333816073f92cf2a8ccdeb"}, + {file = "triton-3.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7936b18a3499ed62059414d7df563e6c163c5e16c3773678a3ee3d417865035d"}, + {file = "triton-3.4.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e5c1442eaeabae2e2452ae765801bd53cd4ce873cab0d1bdd59a32ab2d9397"}, +] + +[package.dependencies] +setuptools = ">=40.8.0" + +[package.extras] +build = ["cmake (>=3.20,<4.0)", "lit"] +tests = ["autopep8", "isort", "llnl-hatchet", "numpy", "pytest", "pytest-forked", "pytest-xdist", "scipy (>=1.7.1)"] +tutorials = ["matplotlib", "pandas", "tabulate"] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -450,6 +2307,27 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "urllib3" +version = "2.5.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] + +[package.dependencies] +pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "uvicorn" version = "0.35.0" @@ -469,6 +2347,23 @@ h11 = ">=0.8" [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +[[package]] +name = "websocket-client" +version = "1.8.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "win32-setctime" version = "1.2.0" @@ -485,7 +2380,22 @@ files = [ [package.extras] dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] +[[package]] +name = "wsproto" +version = "1.2.0" +description = "WebSockets state-machine based protocol implementation" +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + +[package.dependencies] +h11 = ">=0.9.0,<1" + [metadata] lock-version = "2.1" -python-versions = ">=3.11,<4.0" -content-hash = "845e1778efdd87512efdd30eb0ba01aa1383061f662ccc3faa17ab1f8cebde5b" +python-versions = ">=3.11,<3.14" +content-hash = "30722a9f9497e4264b15e7af55b9f8eeb44781a8800f571e477fc146a340179e" diff --git a/apps/pre-processing-service/pyproject.toml b/apps/pre-processing-service/pyproject.toml index 5a2017c3..af7d2124 100644 --- a/apps/pre-processing-service/pyproject.toml +++ b/apps/pre-processing-service/pyproject.toml @@ -6,14 +6,29 @@ authors = [ {name = "skip"} ] readme = "README.md" -requires-python = ">=3.11,<4.0" +requires-python = ">=3.11,<3.14" dependencies = [ "fastapi (>=0.116.1,<0.117.0)", "uvicorn (>=0.35.0,<0.36.0)", "loguru (>=0.7.3,<0.8.0)", "pytest (>=8.4.1,<9.0.0)", "dotenv (>=0.9.9,<0.10.0)", - "pydantic-settings (>=2.10.1,<3.0.0)" + "pydantic-settings (>=2.10.1,<3.0.0)", + "psycopg2-binary (>=2.9.10,<3.0.0)", + "asyncpg (>=0.30.0,<0.31.0)", + "gunicorn (>=23.0.0,<24.0.0)", + "requests (>=2.32.5,<3.0.0)", + "bs4 (>=0.0.2,<0.0.3)", + "selenium (>=4.35.0,<5.0.0)", + "transformers (>=4.56.0,<5.0.0)", + "numpy (>=2.3.2,<3.0.0)", + "torch (>=2.8.0,<3.0.0)", + "scikit-learn (>=1.7.1,<2.0.0)", + "python-dotenv (>=1.1.1,<2.0.0)", + "mecab-python3 (>=1.0.10,<2.0.0)", + "httpx (>=0.28.1,<0.29.0)", + "asyncpg (>=0.30.0,<0.31.0)", + "gunicorn (>=23.0.0,<24.0.0)", ] diff --git a/apps/user-service/build.gradle b/apps/user-service/build.gradle index 4da4d368..145af4f6 100644 --- a/apps/user-service/build.gradle +++ b/apps/user-service/build.gradle @@ -35,10 +35,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-validation' // MyBatis implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5' + // batch + implementation 'org.springframework.boot:spring-boot-starter-batch' + // Log4j2 - 모든 모듈을 2.22.1로 통일 implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'org.apache.logging.log4j:log4j-core:2.22.1' @@ -56,7 +60,10 @@ dependencies { // Database runtimeOnly 'com.h2database:h2' - runtimeOnly 'org.postgresql:postgresql' + implementation 'org.mariadb.jdbc:mariadb-java-client:3.3.3' + + // Development dependencies + developmentOnly 'org.springframework.boot:spring-boot-docker-compose' // Test Dependencies testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -64,10 +71,33 @@ dependencies { testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.5' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mariadb' + testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { + useJUnitPlatform { + // 기본적으로는 e2e 태그 제외하고 실행 + excludeTags 'e2e' + } + systemProperty 'spring.profiles.active', 'test-unit' +} + +// E2E 테스트 전용 task 추가 +tasks.register('e2eTest', Test) { + useJUnitPlatform { + includeTags 'e2e' + } + + systemProperty 'spring.profiles.active', 'test-e2e' + + // E2E 테스트는 더 긴 시간 허용 + timeout = Duration.ofMinutes(10) +} + +// 모든 테스트 실행 task +tasks.register('allTests', Test) { useJUnitPlatform() } diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java b/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java index c69c1773..002a6bc4 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/UserServiceApplication.java @@ -1,9 +1,13 @@ package com.gltkorea.icebang; import org.mybatis.spring.annotation.MapperScan; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling +@EnableBatchProcessing @SpringBootApplication @MapperScan("com.gltkorea.icebang.mapper") public class UserServiceApplication { diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/controller/AuthController.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/controller/AuthController.java deleted file mode 100644 index 1e2f9dae..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/controller/AuthController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.gltkorea.icebang.auth.controller; - -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.gltkorea.icebang.auth.dto.DefaultRequestWrapper; -import com.gltkorea.icebang.auth.dto.LoginDto; -import com.gltkorea.icebang.auth.dto.SignUpDto; -import com.gltkorea.icebang.auth.provider.AuthProvider; -import com.gltkorea.icebang.auth.provider.AuthProviderFactory; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/v0/auth") -public class AuthController { - private final AuthProviderFactory authProviderFactory; - - @PostMapping("/signup") - public ResponseEntity signUp(@RequestBody SignUpDto signUpDto) { - // 1. Wrapper DTO 생성 - DefaultRequestWrapper wrapper = DefaultRequestWrapper.builder().signUpDto(signUpDto).build(); - - // 2. Factory에서 Provider 선택 - @SuppressWarnings("unchecked") - AuthProvider provider = - (AuthProvider) authProviderFactory.getProvider("default"); - - // 3. Provider에 인증 위임 (Provider 내부에서 signUp + login 처리) - Authentication auth = provider.authenticate(wrapper); - - // 4. 결과 반환 - return ResponseEntity.status(201).body(auth); - } - - @PostMapping("/signin") - public ResponseEntity signIn(@RequestBody LoginDto loginDto) { - DefaultRequestWrapper wrapper = DefaultRequestWrapper.builder().loginDto(loginDto).build(); - @SuppressWarnings("unchecked") - AuthProvider provider = - (AuthProvider) authProviderFactory.getProvider("default"); - Authentication auth = provider.authenticate(wrapper); - return ResponseEntity.ok(auth); - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/controller/Oauth2CallbackController.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/controller/Oauth2CallbackController.java deleted file mode 100644 index f21d206a..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/controller/Oauth2CallbackController.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.gltkorea.icebang.auth.controller; - -import org.springframework.web.bind.annotation.*; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/v0/oauth2/callback") -@RequiredArgsConstructor -public class Oauth2CallbackController { - - @GetMapping("/kakao") - public void handleKakaoCallback(@RequestParam String code) { - // OAuth2RequestWrapper wrapper = new OAuth2RequestWrapper(new - // Oauth2CallbackContent("kakao", code)); - // OAuth2AuthProvider provider = (OAuth2AuthProvider) - // authProviderFactory.getProvider(providerKey); - // Authentication auth = provider.authenticate(wrapper); - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/AuthRequestWrapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/AuthRequestWrapper.java deleted file mode 100644 index 3aac5ee4..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/AuthRequestWrapper.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.gltkorea.icebang.auth.dto; - -public interface AuthRequestWrapper {} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/DefaultRequestWrapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/DefaultRequestWrapper.java deleted file mode 100644 index 4c2ebc93..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/DefaultRequestWrapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.gltkorea.icebang.auth.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Builder -@AllArgsConstructor -@Getter -public class DefaultRequestWrapper implements AuthRequestWrapper { - private final LoginDto loginDto; - private final SignUpDto signUpDto; -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/LoginDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/LoginDto.java deleted file mode 100644 index 73fda179..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/LoginDto.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.gltkorea.icebang.auth.dto; - -public class LoginDto {} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/OAuth2RequestWrapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/OAuth2RequestWrapper.java deleted file mode 100644 index 86e9b5d7..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/OAuth2RequestWrapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.gltkorea.icebang.auth.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -@AllArgsConstructor -public class OAuth2RequestWrapper implements AuthRequestWrapper { - private Oauth2CallbackContent callbackContent; -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/Oauth2CallbackContent.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/Oauth2CallbackContent.java deleted file mode 100644 index d496b534..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/Oauth2CallbackContent.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.gltkorea.icebang.auth.dto; - -public class Oauth2CallbackContent { - private String code; - private String state; - private String provider; -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/SignUpDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/SignUpDto.java deleted file mode 100644 index 5dc869fa..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/dto/SignUpDto.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.gltkorea.icebang.auth.dto; - -public class SignUpDto {} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/AuthProvider.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/AuthProvider.java deleted file mode 100644 index 8380e96e..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/AuthProvider.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.gltkorea.icebang.auth.provider; - -import org.springframework.security.core.Authentication; - -import com.gltkorea.icebang.auth.dto.AuthRequestWrapper; - -public interface AuthProvider { - boolean supports(T request); - - Authentication authenticate(T request); -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/AuthProviderFactory.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/AuthProviderFactory.java deleted file mode 100644 index 4f41363e..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/AuthProviderFactory.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.gltkorea.icebang.auth.provider; - -import java.util.Map; - -import org.springframework.stereotype.Component; - -import com.gltkorea.icebang.auth.dto.AuthRequestWrapper; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class AuthProviderFactory { - - private final Map> providers; - - /** - * providerKey에 해당하는 AuthProvider 반환 - * - * @param providerKey "google", "naver", "default" 등, enum으로 refactoring 필요 - * @return AuthProvider - */ - public AuthProvider getProvider(String providerKey) { - AuthProvider provider = providers.get(providerKey.toLowerCase()); - if (provider == null) { - throw new IllegalArgumentException("Unknown auth provider: " + providerKey); - } - return provider; - } - - /** - * OAuth2 전용 Provider 반환 - * - * @param providerKey OAuth2 provider key - * @return OAuth2AuthProvider - */ - public OAuth2AuthProvider getOAuth2Provider(String providerKey) { - AuthProvider provider = getProvider(providerKey); - if (!(provider instanceof OAuth2AuthProvider oauthProvider)) { - throw new IllegalArgumentException(providerKey + " is not an OAuth2 provider"); - } - return oauthProvider; - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/DefaultProvider.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/DefaultProvider.java deleted file mode 100644 index 40a44c7c..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/DefaultProvider.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.gltkorea.icebang.auth.provider; - -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Component; - -import com.gltkorea.icebang.auth.dto.DefaultRequestWrapper; - -@Component("default") -public class DefaultProvider implements AuthProvider { - @Override - public boolean supports(DefaultRequestWrapper request) { - return false; - } - - @Override - public Authentication authenticate(DefaultRequestWrapper request) { - return null; - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/OAuth2AuthProvider.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/OAuth2AuthProvider.java deleted file mode 100644 index 30be51f4..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/provider/OAuth2AuthProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.gltkorea.icebang.auth.provider; - -import org.springframework.security.core.Authentication; - -import com.gltkorea.icebang.auth.dto.OAuth2RequestWrapper; - -public interface OAuth2AuthProvider extends AuthProvider { - Authentication authenticateWithCode(OAuth2RequestWrapper oauthContent); -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/AuthService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/AuthService.java deleted file mode 100644 index 30a2a131..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/AuthService.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.gltkorea.icebang.auth.service; - -import com.gltkorea.icebang.auth.dto.LoginDto; -import com.gltkorea.icebang.auth.dto.SignUpDto; -import com.gltkorea.icebang.domain.user.model.Users; - -public interface AuthService { - Users signUp(SignUpDto signUpDto); - - Users login(LoginDto loginDto); - - Users loadUser(String identifier); -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/AuthServiceImpl.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/AuthServiceImpl.java deleted file mode 100644 index aa118d2d..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/AuthServiceImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.gltkorea.icebang.auth.service; - -import org.springframework.stereotype.Service; - -import com.gltkorea.icebang.auth.dto.LoginDto; -import com.gltkorea.icebang.auth.dto.SignUpDto; -import com.gltkorea.icebang.domain.user.model.Users; - -@Service -public class AuthServiceImpl implements AuthService { - @Override - public Users signUp(SignUpDto signUpDto) { - return null; - } - - @Override - public Users login(LoginDto loginDto) { - return null; - } - - @Override - public Users loadUser(String identifier) { - return null; - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/AuthStateService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/AuthStateService.java deleted file mode 100644 index c802b309..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/AuthStateService.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.gltkorea.icebang.auth.service.state; - -import org.springframework.security.core.userdetails.UserDetails; - -public sealed interface AuthStateService permits SessionStateService, JwtTokenStateService { - String create(UserDetails userDetails); - - UserDetails validate(String identifier); -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/JwtTokenStateService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/JwtTokenStateService.java deleted file mode 100644 index 9065820e..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/JwtTokenStateService.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.gltkorea.icebang.auth.service.state; - -import org.springframework.security.core.userdetails.UserDetails; - -public final class JwtTokenStateService implements AuthStateService { - @Override - public String create(UserDetails userDetails) { - return ""; - } - - @Override - public UserDetails validate(String identifier) { - return null; - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/SessionStateService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/SessionStateService.java deleted file mode 100644 index 9bed4d1a..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/auth/service/state/SessionStateService.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.gltkorea.icebang.auth.service.state; - -import org.springframework.security.core.userdetails.UserDetails; - -public final class SessionStateService implements AuthStateService { - @Override - public String create(UserDetails userDetails) { - return ""; - } - - @Override - public UserDetails validate(String identifier) { - return null; - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java new file mode 100644 index 00000000..61626411 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/job/BlogContentJobConfig.java @@ -0,0 +1,51 @@ +package com.gltkorea.icebang.batch.job; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.gltkorea.icebang.batch.tasklet.ContentGenerationTasklet; +import com.gltkorea.icebang.batch.tasklet.KeywordExtractionTasklet; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class BlogContentJobConfig { + + // 변경점 1: Factory 대신 실제 Tasklet만 필드로 주입받습니다. + private final KeywordExtractionTasklet keywordExtractionTasklet; + private final ContentGenerationTasklet contentGenerationTasklet; + + @Bean + public Job blogContentJob( + JobRepository jobRepository, Step keywordExtractionStep, Step contentGenerationStep) { + return new JobBuilder("blogContentJob", jobRepository) // 변경점 2: JobBuilder를 직접 생성합니다. + .start(keywordExtractionStep) + .next(contentGenerationStep) + .build(); + } + + @Bean + public Step keywordExtractionStep( + JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("keywordExtractionStep", jobRepository) // 변경점 3: StepBuilder를 직접 생성합니다. + .tasklet( + keywordExtractionTasklet, + transactionManager) // 변경점 4: tasklet에 transactionManager를 함께 전달합니다. + .build(); + } + + @Bean + public Step contentGenerationStep( + JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("contentGenerationStep", jobRepository) + .tasklet(contentGenerationTasklet, transactionManager) + .build(); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java new file mode 100644 index 00000000..5cc8918a --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/ContentGenerationTasklet.java @@ -0,0 +1,49 @@ +package com.gltkorea.icebang.batch.tasklet; + +import java.util.List; + +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ContentGenerationTasklet implements Tasklet { + + // private final ContentService contentService; // 비즈니스 로직을 담은 서비스 + // private final FastApiClient fastApiClient; // FastAPI 통신을 위한 클라이언트 + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) + throws Exception { + log.info(">>>> [Step 2] ContentGenerationTasklet executed."); + + // --- 핵심: JobExecutionContext에서 이전 Step의 결과물 가져오기 --- + ExecutionContext jobExecutionContext = + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); + + // KeywordExtractionTasklet이 저장한 "extractedKeywordIds" Key로 데이터 조회 + List keywordIds = (List) jobExecutionContext.get("extractedKeywordIds"); + + if (keywordIds == null || keywordIds.isEmpty()) { + log.warn(">>>> No keyword IDs found from previous step. Skipping content generation."); + return RepeatStatus.FINISHED; + } + + log.info(">>>> Received Keyword IDs for content generation: {}", keywordIds); + + // TODO: 1. 전달받은 키워드 ID 목록으로 DB에서 상세 정보 조회 + // TODO: 2. 각 키워드/상품 정보에 대해 외부 AI 서비스(FastAPI/LangChain)를 호출하여 콘텐츠 생성을 요청 + // TODO: 3. 생성된 콘텐츠를 DB에 저장 + + log.info(">>>> [Step 2] ContentGenerationTasklet finished."); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java new file mode 100644 index 00000000..520403b3 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/batch/tasklet/KeywordExtractionTasklet.java @@ -0,0 +1,47 @@ +package com.gltkorea.icebang.batch.tasklet; + +import java.util.List; + +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KeywordExtractionTasklet implements Tasklet { + + // private final TrendKeywordService trendKeywordService; // 비즈니스 로직을 담은 서비스 + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) + throws Exception { + log.info(">>>> [Step 1] KeywordExtractionTasklet executed."); + + // TODO: 1. DB에서 카테고리 정보 조회 + // TODO: 2. 외부 API 또는 내부 로직을 통해 트렌드 키워드 추출 + // TODO: 3. 추출된 키워드를 DB에 저장 + + // --- 핵심: 다음 Step에 전달할 데이터 생성 --- + // 예시: 새로 생성된 키워드 ID 목록을 가져왔다고 가정 + List extractedKeywordIds = List.of(1L, 2L, 3L); // 실제로는 DB 저장 후 반환된 ID 목록 + log.info(">>>> Extracted Keyword IDs: {}", extractedKeywordIds); + + // --- 핵심: JobExecutionContext에 결과물 저장 --- + // JobExecution 전체에서 공유되는 컨텍스트를 가져옵니다. + ExecutionContext jobExecutionContext = + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext(); + + // "extractedKeywordIds" 라는 Key로 데이터 저장 + jobExecutionContext.put("extractedKeywordIds", extractedKeywordIds); + + log.info(">>>> [Step 1] KeywordExtractionTasklet finished."); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/common/dto/ApiResponse.java b/apps/user-service/src/main/java/com/gltkorea/icebang/common/dto/ApiResponse.java new file mode 100644 index 00000000..7cf5edb3 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/common/dto/ApiResponse.java @@ -0,0 +1,38 @@ +package com.gltkorea.icebang.common.dto; + +import org.springframework.http.HttpStatus; + +import lombok.Data; + +@Data +public class ApiResponse { + private boolean success; + private T data; + private String message; + private HttpStatus status; // HttpStatus로 변경 + + public ApiResponse() {} + + public ApiResponse(boolean success, T data, String message, HttpStatus status) { + this.success = success; + this.data = data; + this.message = message; + this.status = status; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, "OK", HttpStatus.OK); + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(true, data, message, HttpStatus.OK); + } + + public static ApiResponse success(T data, String message, HttpStatus status) { + return new ApiResponse<>(true, data, message, status); + } + + public static ApiResponse error(String message, HttpStatus status) { + return new ApiResponse<>(false, null, message, status); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/common/utils/RandomPasswordGenerator.java b/apps/user-service/src/main/java/com/gltkorea/icebang/common/utils/RandomPasswordGenerator.java new file mode 100644 index 00000000..3716e5b6 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/common/utils/RandomPasswordGenerator.java @@ -0,0 +1,62 @@ +package com.gltkorea.icebang.common.utils; + +import java.security.SecureRandom; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.springframework.stereotype.Component; + +@Component +public class RandomPasswordGenerator { + + private static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; + private static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String DIGITS = "0123456789"; + private static final String SPECIAL_CHARS = "!@#$%^&*()-_+=<>?"; + + private final SecureRandom random = new SecureRandom(); + + public String generate(int length) { + if (length < 8) { + length = 8; + } + + StringBuilder passwordBuilder = new StringBuilder(); + passwordBuilder.append(getRandomChar(LOWERCASE)); + passwordBuilder.append(getRandomChar(UPPERCASE)); + passwordBuilder.append(getRandomChar(DIGITS)); + passwordBuilder.append(getRandomChar(SPECIAL_CHARS)); + + // 나머지 길이를 채우기 위해 모든 문자 집합을 사용 + String allChars = LOWERCASE + UPPERCASE + DIGITS + SPECIAL_CHARS; + IntStream.range(4, length) + .forEach( + i -> { + passwordBuilder.append(getRandomChar(allChars)); + }); + + // 생성된 문자열을 리스트로 변환하여 섞기 + List passwordChars = + passwordBuilder.chars().mapToObj(c -> (char) c).collect(Collectors.toList()); + + Collections.shuffle(passwordChars, random); + + // 다시 문자열로 합치기 + return passwordChars.stream() + .collect(StringBuilder::new, StringBuilder::append, StringBuilder::append) + .toString(); + } + + // 특정 문자열에서 랜덤으로 한 문자 선택 + private char getRandomChar(String charSet) { + int randomIndex = random.nextInt(charSet.length()); + return charSet.charAt(randomIndex); + } + + // 기본 길이로 비밀번호 생성 + public String generate() { + return generate(12); // 기본 길이를 12로 설정 + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java new file mode 100644 index 00000000..592eb0d7 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/scheduler/SchedulerConfig.java @@ -0,0 +1,28 @@ +package com.gltkorea.icebang.config.scheduler; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** 동적 스케줄링을 위한 TaskScheduler Bean을 설정하는 클래스 */ +@Configuration +public class SchedulerConfig { + + @Bean + public TaskScheduler taskScheduler() { + // ThreadPool 기반의 TaskScheduler를 생성합니다. + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + + // 스케줄러가 사용할 스레드 풀의 크기를 설정합니다. + // 동시에 실행될 수 있는 스케줄 작업의 최대 개수입니다. + scheduler.setPoolSize(10); + + // 스레드 이름의 접두사를 설정하여 로그 추적을 용이하게 합니다. + scheduler.setThreadNamePrefix("dynamic-scheduler-"); + + // 스케줄러를 초기화합니다. + scheduler.initialize(); + return scheduler; + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java index 8a81b429..4a2fff36 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -31,14 +32,33 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { auth -> auth.requestMatchers(SecurityEndpoints.PUBLIC.getMatchers()) .permitAll() - .requestMatchers(SecurityEndpoints.ADMIN.getMatchers()) - .hasRole("ADMIN") + .requestMatchers("/auth/login", "/auth/logout") + .permitAll() + .requestMatchers(SecurityEndpoints.DATA_ADMIN.getMatchers()) + .hasAuthority("SUPER_ADMIN") + .requestMatchers(SecurityEndpoints.DATA_ENGINEER.getMatchers()) + .hasAnyAuthority( + "SUPER_ADMIN", "ADMIN", "SENIOR_DATA_ENGINEER", "DATA_ENGINEER") + .requestMatchers(SecurityEndpoints.ANALYST.getMatchers()) + .hasAnyAuthority( + "SUPER_ADMIN", + "ADMIN", + "SENIOR_DATA_ENGINEER", + "DATA_ENGINEER", + "SENIOR_DATA_ANALYST", + "DATA_ANALYST", + "VIEWER") + .requestMatchers(SecurityEndpoints.OPS.getMatchers()) + .hasAnyAuthority( + "SUPER_ADMIN", "ADMIN", "SENIOR_DATA_ENGINEER", "DATA_ENGINEER") .requestMatchers(SecurityEndpoints.USER.getMatchers()) - .hasRole("USER") + .authenticated() .anyRequest() .authenticated()) - .formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/").permitAll()) - .logout(logout -> logout.logoutSuccessUrl("/login").permitAll()) + .formLogin(AbstractHttpConfigurer::disable) + .logout( + logout -> logout.logoutUrl("/auth/logout").logoutSuccessUrl("/auth/login").permitAll()) + .csrf(AbstractHttpConfigurer::disable) // API 사용을 위해 CSRF 비활성화 .build(); } diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java index 0a24605f..c73f462d 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/config/security/endpoints/SecurityEndpoints.java @@ -2,11 +2,30 @@ public enum SecurityEndpoints { PUBLIC( - "/", "/login", "/register", "/api/public/**", "/health", "/css/**", "/js/**", "/images/**"), + "/", + "/login", + "/register", + "/api/public/**", + "/health", + "/css/**", + "/js/**", + "/images/**", + "/v0/**"), - ADMIN("/admin/**", "/api/admin/**", "/management/**", "/actuator/**"), + // 데이터 관리 관련 엔드포인트 + DATA_ADMIN("/admin/**", "/api/admin/**", "/management/**", "/actuator/**"), - USER("/user/**", "/api/user/**", "/profile/**", "/dashboard"); + // 데이터 엔지니어 전용 엔드포인트 + DATA_ENGINEER("/api/preprocessing/**", "/api/pipeline/**", "/api/jobs/**"), + + // 분석가 전용 엔드포인트 + ANALYST("/api/analysis/**", "/api/reports/**", "/api/dashboard/**"), + + // 운영 관련 엔드포인트 + OPS("/api/scheduler/**", "/api/monitoring/**"), + + // 일반 사용자 엔드포인트 + USER("/user/**", "/profile/**"); private final String[] patterns; diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/controller/AuthController.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..5da466f6 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/controller/AuthController.java @@ -0,0 +1,25 @@ +package com.gltkorea.icebang.domain.auth.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import com.gltkorea.icebang.common.dto.ApiResponse; +import com.gltkorea.icebang.domain.auth.dto.RegisterDto; +import com.gltkorea.icebang.domain.auth.service.AuthService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v0/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthService authService; + + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register(@Valid @RequestBody RegisterDto registerDto) { + authService.registerUser(registerDto); + return ApiResponse.success(null); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/dto/RegisterDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/dto/RegisterDto.java new file mode 100644 index 00000000..1ff305aa --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/dto/RegisterDto.java @@ -0,0 +1,46 @@ +package com.gltkorea.icebang.domain.auth.dto; + +import java.math.BigInteger; +import java.util.Set; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Null; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RegisterDto { + @Null private BigInteger id; + + @NotBlank(message = "사용자명은 필수입니다") + private String name; + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; + + @Null private BigInteger userOrgId; + + @NotNull(message = "조직 선택은 필수입니다") + private BigInteger orgId; + + @NotNull(message = "부서 선택은 필수입니다") + private BigInteger deptId; + + @NotNull(message = "직책 선택은 필수입니다") + private BigInteger positionId; + + @NotNull(message = "역할 선택은 필수입니다") + private Set roleIds; + + @Null private String password; + + @Null private String status; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/service/AuthService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/service/AuthService.java new file mode 100644 index 00000000..18010ed5 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/auth/service/AuthService.java @@ -0,0 +1,53 @@ +package com.gltkorea.icebang.domain.auth.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.gltkorea.icebang.common.utils.RandomPasswordGenerator; +import com.gltkorea.icebang.domain.auth.dto.RegisterDto; +import com.gltkorea.icebang.domain.email.dto.EmailRequest; +import com.gltkorea.icebang.domain.email.service.EmailService; +import com.gltkorea.icebang.mapper.AuthMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + private final AuthMapper authMapper; + private final RandomPasswordGenerator passwordGenerator; + private final PasswordEncoder passwordEncoder; + private final EmailService emailService; + + public void registerUser(RegisterDto registerDto) { + if (authMapper.existsByEmail(registerDto.getEmail())) { + throw new IllegalArgumentException("이미 가입된 이메일입니다."); + } + String randomPassword = passwordGenerator.generate(); + String hashedPassword = passwordEncoder.encode(randomPassword); + + registerDto.setPassword(hashedPassword); + registerDto.setStatus("PENDING"); + + authMapper.insertUser(registerDto); + + // 2. user_organizations insert → userOrgId 반환 + authMapper.insertUserOrganization(registerDto); + + // 3. user_roles insert (foreach) + if (registerDto.getRoleIds() != null && !registerDto.getRoleIds().isEmpty()) { + authMapper.insertUserRoles(registerDto); + } + + EmailRequest emailRequest = + EmailRequest.builder() + .to(registerDto.getEmail()) + .subject("[ice-bang] 비밀번호") + .body(randomPassword) + .build(); + + emailService.send(emailRequest); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/department/dto/DepartmentsCardDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/department/dto/DepartmentsCardDto.java new file mode 100644 index 00000000..5f50fabd --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/department/dto/DepartmentsCardDto.java @@ -0,0 +1,15 @@ +package com.gltkorea.icebang.domain.department.dto; + +import java.math.BigInteger; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class DepartmentsCardDto { + private BigInteger id; + private String name; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/dto/EmailRequest.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/dto/EmailRequest.java new file mode 100644 index 00000000..fbd25749 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/dto/EmailRequest.java @@ -0,0 +1,17 @@ +package com.gltkorea.icebang.domain.email.dto; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class EmailRequest { + private String to; + private String subject; + private String body; + private List cc; + private List bcc; + private boolean isHtml; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/EmailService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/EmailService.java new file mode 100644 index 00000000..ac0b6663 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/EmailService.java @@ -0,0 +1,7 @@ +package com.gltkorea.icebang.domain.email.service; + +import com.gltkorea.icebang.domain.email.dto.EmailRequest; + +public interface EmailService { + void send(EmailRequest emailRequest); +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/EmailServiceImpl.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/EmailServiceImpl.java new file mode 100644 index 00000000..a992de13 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/EmailServiceImpl.java @@ -0,0 +1,16 @@ +package com.gltkorea.icebang.domain.email.service; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.stereotype.Service; + +import com.gltkorea.icebang.domain.email.dto.EmailRequest; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@ConditionalOnMissingBean(EmailService.class) +public class EmailServiceImpl implements EmailService { + @Override + public void send(EmailRequest emailRequest) {} +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/MockEmailService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/MockEmailService.java new file mode 100644 index 00000000..6ccaffc9 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/email/service/MockEmailService.java @@ -0,0 +1,21 @@ +package com.gltkorea.icebang.domain.email.service; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import com.gltkorea.icebang.domain.email.dto.EmailRequest; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Profile({"test-unit", "test-e2e", "local", "develop"}) +@Slf4j +public class MockEmailService implements EmailService { + + @Override + public void send(EmailRequest emailRequest) { + log.info("Mock send mail to: {}", emailRequest.getTo()); + log.info("Subject: {}", emailRequest.getSubject()); + log.info("Body: {}", emailRequest.getBody()); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/controller/OrganizationController.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/controller/OrganizationController.java new file mode 100644 index 00000000..ff3567b9 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/controller/OrganizationController.java @@ -0,0 +1,35 @@ +package com.gltkorea.icebang.domain.organization.controller; + +import java.math.BigInteger; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.gltkorea.icebang.common.dto.ApiResponse; +import com.gltkorea.icebang.domain.organization.dto.OrganizationCardDto; +import com.gltkorea.icebang.domain.organization.dto.OrganizationOptionsDto; +import com.gltkorea.icebang.domain.organization.service.OrganizationService; + +import lombok.RequiredArgsConstructor; + +@RequestMapping("/v0/organizations") +@RequiredArgsConstructor +@RestController +public class OrganizationController { + private final OrganizationService organizationService; + + @GetMapping("") + public ResponseEntity>> getOrganizations() { + return ResponseEntity.ok(ApiResponse.success(organizationService.getAllOrganizationList())); + } + + @GetMapping("/{id}/options") + public ResponseEntity> getOrganizationDetails( + @PathVariable BigInteger id) { + return ResponseEntity.ok(ApiResponse.success(organizationService.getOrganizationOptions(id))); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/dto/OrganizationCardDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/dto/OrganizationCardDto.java new file mode 100644 index 00000000..af0ef64b --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/dto/OrganizationCardDto.java @@ -0,0 +1,15 @@ +package com.gltkorea.icebang.domain.organization.dto; + +import java.math.BigInteger; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class OrganizationCardDto { + private BigInteger id; + private String organizationName; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/dto/OrganizationOptionsDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/dto/OrganizationOptionsDto.java new file mode 100644 index 00000000..c416d811 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/dto/OrganizationOptionsDto.java @@ -0,0 +1,20 @@ +package com.gltkorea.icebang.domain.organization.dto; + +import java.util.List; + +import com.gltkorea.icebang.domain.department.dto.DepartmentsCardDto; +import com.gltkorea.icebang.domain.position.dto.PositionCardDto; +import com.gltkorea.icebang.domain.roles.dto.RolesCardDto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +@AllArgsConstructor +public class OrganizationOptionsDto { + List departments; + List positions; + List roles; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/service/OrganizationService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/service/OrganizationService.java new file mode 100644 index 00000000..4cebdfe5 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/organization/service/OrganizationService.java @@ -0,0 +1,39 @@ +package com.gltkorea.icebang.domain.organization.service; + +import java.math.BigInteger; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.gltkorea.icebang.domain.department.dto.DepartmentsCardDto; +import com.gltkorea.icebang.domain.organization.dto.OrganizationCardDto; +import com.gltkorea.icebang.domain.organization.dto.OrganizationOptionsDto; +import com.gltkorea.icebang.domain.position.dto.PositionCardDto; +import com.gltkorea.icebang.domain.roles.dto.RolesCardDto; +import com.gltkorea.icebang.mapper.OrganizationMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OrganizationService { + private final OrganizationMapper organizationMapper; + + @Transactional(readOnly = true) + public List getAllOrganizationList() { + return organizationMapper.findAllOrganizations(); + } + + public OrganizationOptionsDto getOrganizationOptions(BigInteger id) { + List departments = organizationMapper.findDepartmentsByOrganizationId(id); + List positions = organizationMapper.findPositionsByOrganizationId(id); + List roles = organizationMapper.findRolesByOrganizationId(id); + + return OrganizationOptionsDto.builder() + .departments(departments) + .positions(positions) + .roles(roles) + .build(); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/position/dto/PositionCardDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/position/dto/PositionCardDto.java new file mode 100644 index 00000000..e97d7d3f --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/position/dto/PositionCardDto.java @@ -0,0 +1,17 @@ +package com.gltkorea.icebang.domain.position.dto; + +import java.math.BigInteger; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class PositionCardDto { + private BigInteger id; + private String title; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/roles/dto/RolesCardDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/roles/dto/RolesCardDto.java new file mode 100644 index 00000000..709a08ff --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/roles/dto/RolesCardDto.java @@ -0,0 +1,18 @@ +package com.gltkorea.icebang.domain.roles.dto; + +import java.math.BigInteger; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RolesCardDto { + private BigInteger id; + private String name; + private String description; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java new file mode 100644 index 00000000..b9400b88 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/model/Schedule.java @@ -0,0 +1,14 @@ +package com.gltkorea.icebang.domain.schedule.model; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Schedule { + private Long scheduleId; + private Long workflowId; + private String cronExpression; + private boolean isActive; + // ... 기타 필요한 컬럼 +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java new file mode 100644 index 00000000..7f96bba8 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/runner/SchedulerInitializer.java @@ -0,0 +1,31 @@ +package com.gltkorea.icebang.domain.schedule.runner; + +import java.util.List; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import com.gltkorea.icebang.domain.schedule.model.Schedule; +import com.gltkorea.icebang.domain.schedule.service.DynamicSchedulerService; +import com.gltkorea.icebang.mapper.ScheduleMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SchedulerInitializer implements ApplicationRunner { + + private final ScheduleMapper scheduleMapper; + private final DynamicSchedulerService dynamicSchedulerService; + + @Override + public void run(ApplicationArguments args) { + log.info(">>>> Initializing schedules from database..."); + List activeSchedules = scheduleMapper.findAllByIsActive(true); + activeSchedules.forEach(dynamicSchedulerService::register); + log.info(">>>> {} active schedules have been registered.", activeSchedules.size()); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/service/DynamicSchedulerService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/service/DynamicSchedulerService.java new file mode 100644 index 00000000..a8bbeff1 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/schedule/service/DynamicSchedulerService.java @@ -0,0 +1,66 @@ +package com.gltkorea.icebang.domain.schedule.service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Service; + +import com.gltkorea.icebang.domain.schedule.model.Schedule; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DynamicSchedulerService { + + private final TaskScheduler taskScheduler; + private final JobLauncher jobLauncher; + private final ApplicationContext applicationContext; + private final Map> scheduledTasks = new ConcurrentHashMap<>(); + + public void register(Schedule schedule) { + // TODO: schedule.getWorkflowId()를 기반으로 실행할 Job의 이름을 DB에서 조회 + String jobName = "blogContentJob"; // 예시 + Job jobToRun = applicationContext.getBean(jobName, Job.class); + + Runnable runnable = + () -> { + try { + JobParametersBuilder paramsBuilder = new JobParametersBuilder(); + paramsBuilder.addString("runAt", LocalDateTime.now().toString()); + paramsBuilder.addLong("scheduleId", schedule.getScheduleId()); + jobLauncher.run(jobToRun, paramsBuilder.toJobParameters()); + } catch (Exception e) { + log.error( + "Failed to run scheduled job for scheduleId: {}", schedule.getScheduleId(), e); + } + }; + + CronTrigger trigger = new CronTrigger(schedule.getCronExpression()); + ScheduledFuture future = taskScheduler.schedule(runnable, trigger); + scheduledTasks.put(schedule.getScheduleId(), future); + log.info( + ">>>> Schedule registered: id={}, cron={}", + schedule.getScheduleId(), + schedule.getCronExpression()); + } + + public void remove(Long scheduleId) { + ScheduledFuture future = scheduledTasks.get(scheduleId); + if (future != null) { + future.cancel(true); + scheduledTasks.remove(scheduleId); + log.info(">>>> Schedule removed: id={}", scheduleId); + } + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/UserStatus.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/UserStatus.java deleted file mode 100644 index b23e47e8..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/UserStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.gltkorea.icebang.domain.user; - -public enum UserStatus { - ONBOARDING, // email, password만 된 경우 - ACTIVE, // 완전히 활성화됨 - SUSPENDED, // 일시 정지 - DELETED // 삭제됨 -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/controller/UserController.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/controller/UserController.java new file mode 100644 index 00000000..e6b07bce --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/controller/UserController.java @@ -0,0 +1,30 @@ +package com.gltkorea.icebang.domain.user.controller; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.gltkorea.icebang.common.dto.ApiResponse; +import com.gltkorea.icebang.domain.user.dto.CheckEmailRequest; +import com.gltkorea.icebang.domain.user.dto.CheckEmailResponse; +import com.gltkorea.icebang.domain.user.service.UserService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v0/users") +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @PostMapping("/check-email") + public ApiResponse checkEmailAvailable( + @Valid @RequestBody CheckEmailRequest request) { + Boolean available = !userService.isExistEmail(request); + String message = available.equals(Boolean.TRUE) ? "사용 가능한 이메일입니다." : "이미 가입된 이메일입니다."; + + return ApiResponse.success(CheckEmailResponse.builder().available(available).build(), message); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/CheckEmailRequest.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/CheckEmailRequest.java new file mode 100644 index 00000000..49208315 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/CheckEmailRequest.java @@ -0,0 +1,14 @@ +package com.gltkorea.icebang.domain.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class CheckEmailRequest { + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/CheckEmailResponse.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/CheckEmailResponse.java new file mode 100644 index 00000000..8b92d187 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/dto/CheckEmailResponse.java @@ -0,0 +1,10 @@ +package com.gltkorea.icebang.domain.user.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CheckEmailResponse { + private Boolean available; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/model/UserAccountPrincipal.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/model/UserAccountPrincipal.java deleted file mode 100644 index abca6682..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/model/UserAccountPrincipal.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.gltkorea.icebang.domain.user.model; - -import java.util.Collection; -import java.util.List; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -import lombok.Builder; - -@Builder -public class UserAccountPrincipal implements UserDetails { - @Override - public Collection getAuthorities() { - return List.of(); - } - - @Override - public String getPassword() { - return ""; - } - - @Override - public String getUsername() { - return ""; - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/model/Users.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/model/Users.java deleted file mode 100644 index ab4d28d1..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/model/Users.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.gltkorea.icebang.domain.user.model; - -import com.gltkorea.icebang.domain.user.UserStatus; - -public class Users { - private UserStatus status; -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/SecurityAuthenticateAdapter.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/SecurityAuthenticateAdapter.java deleted file mode 100644 index f33f546f..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/SecurityAuthenticateAdapter.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.gltkorea.icebang.domain.user.service; - -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UsernameNotFoundException; - -import com.gltkorea.icebang.auth.service.AuthService; -import com.gltkorea.icebang.domain.user.model.UserAccountPrincipal; -import com.gltkorea.icebang.domain.user.model.Users; - -public class SecurityAuthenticateAdapter implements UserAuthService { - private AuthService authService; - - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - Users user = authService.loadUser(username); - return UserAccountPrincipal.builder().build(); // @TODO users -> userdetail로 - } -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/UserAuthService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/UserAuthService.java deleted file mode 100644 index dc8396ee..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/UserAuthService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.gltkorea.icebang.domain.user.service; - -import org.springframework.security.core.userdetails.UserDetailsService; - -public interface UserAuthService extends UserDetailsService {} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/UserService.java b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/UserService.java new file mode 100644 index 00000000..fcf87ac9 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/domain/user/service/UserService.java @@ -0,0 +1,33 @@ +package com.gltkorea.icebang.domain.user.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.gltkorea.icebang.domain.auth.dto.RegisterDto; +import com.gltkorea.icebang.domain.user.dto.CheckEmailRequest; +import com.gltkorea.icebang.entity.Users; +import com.gltkorea.icebang.mapper.UserMapper; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserMapper userMapper; + + public void registerUser(RegisterDto registerDto) { + Users user = + Users.builder() + .name(registerDto.getName()) + .email(registerDto.getEmail()) + .password(registerDto.getPassword()) + .status("PENDING") + .build(); + } + + @Transactional(readOnly = true) + public Boolean isExistEmail(@Valid CheckEmailRequest request) { + return userMapper.existsByEmail(request.getEmail()); + } +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/UserDto.java b/apps/user-service/src/main/java/com/gltkorea/icebang/dto/UserDto.java deleted file mode 100644 index 6763bac9..00000000 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/dto/UserDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.gltkorea.icebang.dto; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class UserDto { - private String userId; - private String name; - private String email; - // ... 필요한 다른 필드들 -} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/entity/Users.java b/apps/user-service/src/main/java/com/gltkorea/icebang/entity/Users.java new file mode 100644 index 00000000..44f30244 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/entity/Users.java @@ -0,0 +1,22 @@ +package com.gltkorea.icebang.entity; + +import java.math.BigInteger; +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +@Deprecated +public class Users { + private BigInteger id; + private String name; + private String email; + private String password; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/AuthMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/AuthMapper.java new file mode 100644 index 00000000..09033730 --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/AuthMapper.java @@ -0,0 +1,16 @@ +package com.gltkorea.icebang.mapper; + +import org.apache.ibatis.annotations.Mapper; + +import com.gltkorea.icebang.domain.auth.dto.RegisterDto; + +@Mapper +public interface AuthMapper { + boolean existsByEmail(String email); + + int insertUser(RegisterDto dto); // users insert + + int insertUserOrganization(RegisterDto dto); // user_organizations insert + + int insertUserRoles(RegisterDto dto); // user_roles insert (foreach) +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/OrganizationMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/OrganizationMapper.java new file mode 100644 index 00000000..2643af9f --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/OrganizationMapper.java @@ -0,0 +1,25 @@ +package com.gltkorea.icebang.mapper; + +import java.math.BigInteger; +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import com.gltkorea.icebang.domain.department.dto.DepartmentsCardDto; +import com.gltkorea.icebang.domain.organization.dto.OrganizationCardDto; +import com.gltkorea.icebang.domain.position.dto.PositionCardDto; +import com.gltkorea.icebang.domain.roles.dto.RolesCardDto; + +@Mapper +public interface OrganizationMapper { + List findAllOrganizations(); + + List findDepartmentsByOrganizationId( + @Param("organizationId") BigInteger organizationId); + + List findPositionsByOrganizationId( + @Param("organizationId") BigInteger organizationId); + + List findRolesByOrganizationId(@Param("organizationId") BigInteger organizationId); +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java new file mode 100644 index 00000000..7220dc9e --- /dev/null +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/ScheduleMapper.java @@ -0,0 +1,12 @@ +package com.gltkorea.icebang.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; + +import com.gltkorea.icebang.domain.schedule.model.Schedule; + +@Mapper +public interface ScheduleMapper { + List findAllByIsActive(boolean isActive); +} diff --git a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/UserMapper.java b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/UserMapper.java index f09a152a..734fe8d5 100644 --- a/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/UserMapper.java +++ b/apps/user-service/src/main/java/com/gltkorea/icebang/mapper/UserMapper.java @@ -1,13 +1,11 @@ package com.gltkorea.icebang.mapper; -import java.util.Optional; - import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; -import com.gltkorea.icebang.dto.UserDto; - -@Mapper // Spring이 MyBatis Mapper로 인식하도록 설정 +@Mapper public interface UserMapper { - // XML 파일의 id와 메서드 이름을 일치시켜야 합니다. - Optional findByEmail(String email); + @Select("SELECT COUNT(1) > 0 FROM users WHERE email = #{email}") + boolean existsByEmail(@Param("email") String email); } diff --git a/apps/user-service/src/main/resources/application-develop.yml b/apps/user-service/src/main/resources/application-develop.yml index d640ff77..773a7333 100644 --- a/apps/user-service/src/main/resources/application-develop.yml +++ b/apps/user-service/src/main/resources/application-develop.yml @@ -1,15 +1,19 @@ -# application-develop.yml spring: config: activate: on-profile: develop - - # PostgreSQL 데이터베이스 연결 설정 + + docker: + compose: + file: docker/local/docker-compose.yml # IDE에서 final-4team-icebang을 root로 열어야 합니다. + lifecycle-management: start_only + + # MariaDB 데이터베이스 연결 설정 datasource: - url: jdbc:postgresql://localhost:5432/pre_process - username: postgres + url: jdbc:mariadb://localhost:3306/pre_process + username: mariadb password: qwer1234 - driver-class-name: org.postgresql.Driver + driver-class-name: org.mariadb.jdbc.Driver hikari: connection-timeout: 30000 @@ -19,6 +23,16 @@ spring: minimum-idle: 5 pool-name: HikariCP-MyBatis + sql: + init: + mode: always + schema-locations: classpath:sql/schema.sql + data-locations: + - classpath:sql/00-truncate.sql + - classpath:sql/01-insert-internal-users.sql + - classpath:sql/02-insert-external-users.sql + encoding: UTF-8 + mybatis: mapper-locations: classpath:mybatis/mapper/**/*.xml type-aliases-package: com.gltkorea.icebang.dto @@ -26,4 +40,4 @@ mybatis: map-underscore-to-camel-case: true logging: - config: classpath:log4j2-develop.yml \ No newline at end of file + config: classpath:log4j2-develop.yml diff --git a/apps/user-service/src/main/resources/application-test-e2e.yml b/apps/user-service/src/main/resources/application-test-e2e.yml new file mode 100644 index 00000000..7703f4a3 --- /dev/null +++ b/apps/user-service/src/main/resources/application-test-e2e.yml @@ -0,0 +1,19 @@ +spring: + config: + activate: + on-profile: test-e2e + + sql: + init: + mode: always + schema-locations: classpath:sql/schema.sql + encoding: UTF-8 + +mybatis: + mapper-locations: classpath:mybatis/mapper/**/*.xml + type-aliases-package: com.gltkorea.icebang.dto + configuration: + map-underscore-to-camel-case: true + +logging: + config: classpath:log4j2-production.yml \ No newline at end of file diff --git a/apps/user-service/src/main/resources/application-test-unit.yml b/apps/user-service/src/main/resources/application-test-unit.yml new file mode 100644 index 00000000..fec65f43 --- /dev/null +++ b/apps/user-service/src/main/resources/application-test-unit.yml @@ -0,0 +1,50 @@ +# src/test/resources/application-test-unit.yml +spring: + config: + activate: + on-profile: test-unit + + # H2 인메모리 데이터베이스 설정 (Unit Test용) + datasource: + url: jdbc:h2:mem:testdb;MODE=MariaDB;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + hikari: + connection-init-sql: "SET MODE MariaDB" + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + maximum-pool-size: 10 + minimum-idle: 5 + pool-name: HikariCP-MyBatis + + # H2 웹 콘솔 활성화 (디버깅용) + h2: + console: + enabled: true + + # JPA 설정 (H2용) + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + + # SQL 스크립트 초기화 설정 + sql: + init: + mode: always + schema-locations: classpath:sql/schema.sql + encoding: UTF-8 + +mybatis: + mapper-locations: classpath:mybatis/mapper/**/*.xml + type-aliases-package: com.gltkorea.icebang.dto + configuration: + map-underscore-to-camel-case: true + +logging: + config: classpath:log4j2-test-unit.yml \ No newline at end of file diff --git a/apps/user-service/src/main/resources/application-test.yml b/apps/user-service/src/main/resources/application-test.yml deleted file mode 100644 index 63217124..00000000 --- a/apps/user-service/src/main/resources/application-test.yml +++ /dev/null @@ -1,34 +0,0 @@ -# src/test/resources/application-test.yml -spring: - config: - activate: - on-profile: test - - # PostgreSQL 데이터베이스 연결 설정 - datasource: - url: jdbc:postgresql://localhost:5432/pre_process - username: postgres - password: qwer1234 - driver-class-name: org.postgresql.Driver - - hikari: - connection-timeout: 30000 - idle-timeout: 600000 - max-lifetime: 1800000 - maximum-pool-size: 10 - minimum-idle: 5 - pool-name: HikariCP-MyBatis - - # SQL 스크립트 초기화 설정 추가 - sql: - init: - mode: always # 내장 DB가 아니더라도 항상 스크립트를 실행하도록 설정 - -mybatis: - mapper-locations: classpath:mybatis/mapper/**/*.xml - type-aliases-package: com.gltkorea.icebang.dto - configuration: - map-underscore-to-camel-case: true - -logging: - config: classpath:log4j2-test.yml \ No newline at end of file diff --git a/apps/user-service/src/main/resources/log4j2-develop.yml b/apps/user-service/src/main/resources/log4j2-develop.yml index 63f4c280..d1afc02b 100644 --- a/apps/user-service/src/main/resources/log4j2-develop.yml +++ b/apps/user-service/src/main/resources/log4j2-develop.yml @@ -89,7 +89,7 @@ Configuration: - ref: file-error-appender # 2. 애플리케이션 로그 - - name: com.movement.mvp + - name: com.gltkorea.icebang additivity: "false" level: TRACE AppenderRef: diff --git a/apps/user-service/src/main/resources/log4j2-production.yml b/apps/user-service/src/main/resources/log4j2-production.yml new file mode 100644 index 00000000..d1afc02b --- /dev/null +++ b/apps/user-service/src/main/resources/log4j2-production.yml @@ -0,0 +1,126 @@ +Configuration: + name: develop + + properties: + property: + - name: "log-path" + value: "./logs" + - name: "charset-UTF-8" + value: "UTF-8" + # 통일된 콘솔 패턴 - 모든 로그에 RequestId 포함 + - name: "console-layout-pattern" + value: "%highlight{[%-5level]} [%X{traceId}] %d{MM-dd HH:mm:ss} [%t] %n %msg%n%n" + # 파일용 상세 패턴 - RequestId 포함 + - name: "file-layout-pattern" + value: "[%X{traceId}] %d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" + # 로그 파일 경로들 + - name: "info-log" + value: ${log-path}/user-service/info.log + - name: "error-log" + value: ${log-path}/user-service/error.log + - name: "auth-log" + value: ${log-path}/user-service/auth.log + - name: "json-log" + value: ${log-path}/user-service/json-info.log + + # [Appenders] 로그 기록방식 정의 + Appenders: + # 통일된 콘솔 출력 + Console: + name: console-appender + target: SYSTEM_OUT + PatternLayout: + pattern: ${console-layout-pattern} + + # 롤링 파일 로그 + RollingFile: + name: rolling-file-appender + fileName: ${log-path}/rolling-file.log + filePattern: "logs/archive/rolling-file.log.%d{yyyy-MM-dd-hh-mm}_%i.gz" + PatternLayout: + charset: ${charset-UTF-8} + pattern: ${file-layout-pattern} + Policies: + SizeBasedTriggeringPolicy: + size: "200KB" + TimeBasedTriggeringPolicy: + interval: "1" + DefaultRollOverStrategy: + max: "30" + fileIndex: "max" + + # 파일 로그들 + File: + - name: file-info-appender + fileName: ${info-log} + PatternLayout: + pattern: ${file-layout-pattern} + - name: file-error-appender + fileName: ${error-log} + PatternLayout: + pattern: ${file-layout-pattern} + - name: file-auth-appender + fileName: ${auth-log} + PatternLayout: + pattern: ${file-layout-pattern} + - name: file-json-info-appender + fileName: ${json-log} + PatternLayout: + pattern: ${file-layout-pattern} + + # [Loggers] 로그 출력 범위를 정의 + Loggers: + # [Loggers - Root] 모든 로그를 기록하는 최상위 로그를 정의 + Root: + level: OFF + AppenderRef: + - ref: console-appender + - ref: rolling-file-appender + + # [Loggers - Loggers] 특정 패키지나 클래스에 대한 로그를 정의 + Logger: + # 1. Spring Framework 로그 + - name: org.springframework + additivity: "false" + level: DEBUG + AppenderRef: + - ref: console-appender + - ref: file-info-appender + - ref: file-error-appender + + # 2. 애플리케이션 로그 + - name: com.gltkorea.icebang + additivity: "false" + level: TRACE + AppenderRef: + - ref: console-appender + - ref: file-info-appender + - ref: file-error-appender + + # 3. HikariCP 로그 비활성화 + - name: com.zaxxer.hikari + level: OFF + + # 4. Spring Security 로그 - 인증/인가 추적에 중요 + - name: org.springframework.security + level: DEBUG + additivity: "false" + AppenderRef: + - ref: console-appender + - ref: file-auth-appender + + # 5. 웹 요청 로그 - 요청 처리 과정 추적 + - name: org.springframework.web + level: DEBUG + additivity: "false" + AppenderRef: + - ref: console-appender + - ref: file-info-appender + + # 6. 트랜잭션 로그 - DB 작업 추적 + - name: org.springframework.transaction + level: DEBUG + additivity: "false" + AppenderRef: + - ref: console-appender + - ref: file-info-appender \ No newline at end of file diff --git a/apps/user-service/src/main/resources/log4j2-test.yml b/apps/user-service/src/main/resources/log4j2-test-unit.yml similarity index 98% rename from apps/user-service/src/main/resources/log4j2-test.yml rename to apps/user-service/src/main/resources/log4j2-test-unit.yml index 05333338..80df15cd 100644 --- a/apps/user-service/src/main/resources/log4j2-test.yml +++ b/apps/user-service/src/main/resources/log4j2-test-unit.yml @@ -38,7 +38,7 @@ Configuration: - ref: console-appender # 2. 애플리케이션 로그 - - name: com.movement + - name: com.gltkorea.icebang additivity: "false" level: INFO AppenderRef: diff --git a/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml new file mode 100644 index 00000000..0c36cc21 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/AuthMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + INSERT INTO users (name, email, password) + VALUES (#{name}, #{email}, #{password}); + + + + + INSERT INTO user_organizations (user_id, organization_id, department_id, position_id, status) + VALUES (#{id}, #{orgId}, #{deptId}, #{positionId}, #{status}); + + + + + INSERT INTO user_roles (user_organization_id, role_id) + VALUES + + (#{userOrgId}, #{roleId}) + + + + diff --git a/apps/user-service/src/main/resources/mybatis/mapper/OrganizationMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/OrganizationMapper.xml new file mode 100644 index 00000000..cdc403fb --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/OrganizationMapper.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml new file mode 100644 index 00000000..4a40fe49 --- /dev/null +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/UserMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/UserMapper.xml deleted file mode 100644 index 68be89f9..00000000 --- a/apps/user-service/src/main/resources/mybatis/mapper/UserMapper.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/00-truncate.sql b/apps/user-service/src/main/resources/sql/00-truncate.sql new file mode 100644 index 00000000..93cbfd4a --- /dev/null +++ b/apps/user-service/src/main/resources/sql/00-truncate.sql @@ -0,0 +1,15 @@ +-- 데이터 초기화 전에 추가 +SET FOREIGN_KEY_CHECKS = 0; + +-- 역순으로 TRUNCATE (참조되는 테이블을 나중에) +TRUNCATE TABLE user_roles; +TRUNCATE TABLE role_permissions; +TRUNCATE TABLE user_organizations; +TRUNCATE TABLE users; +TRUNCATE TABLE positions; +TRUNCATE TABLE departments; +TRUNCATE TABLE roles; +TRUNCATE TABLE permissions; +TRUNCATE TABLE organizations; + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql b/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql new file mode 100644 index 00000000..29f1f81a --- /dev/null +++ b/apps/user-service/src/main/resources/sql/01-insert-internal-users.sql @@ -0,0 +1,330 @@ +-- icebang 내부 직원 전체 INSERT + +-- 1. icebang 조직 +INSERT INTO `organizations` (`name`, `domain_name`) VALUES + ('icebang', 'icebang.site'); + +-- 2. icebang 부서들 +INSERT INTO `departments` (`organization_id`, `name`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'AI개발팀'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '데이터팀'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '콘텐츠팀'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '마케팅팀'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '운영팀'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '기획팀'); + +-- 3. icebang 직책들 +INSERT INTO `positions` (`organization_id`, `title`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'CEO'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'CTO'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '팀장'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '시니어'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '주니어'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), '인턴'); + +-- 4. 바이럴 콘텐츠 워크플로우 권한들 +INSERT INTO `permissions` (`resource`, `description`) VALUES +-- 사용자 관리 +('users.create', '사용자 생성'), +('users.read', '사용자 조회'), +('users.read.own', '본인 정보 조회'), +('users.read.department', '부서 내 사용자 조회'), +('users.read.organization', '조직 전체 사용자 조회'), +('users.update', '사용자 정보 수정'), +('users.update.own', '본인 정보 수정'), +('users.delete', '사용자 삭제'), +('users.invite', '사용자 초대'), + +-- 조직 관리 +('organizations.read', '조직 조회'), +('organizations.settings', '조직 설정 관리'), + +-- 부서 관리 +('departments.read', '부서 조회'), +('departments.manage', '부서 관리'), + +-- 역할/권한 관리 +('roles.create', '역할 생성'), +('roles.read', '역할 조회'), +('roles.update', '역할 수정'), +('roles.assign', '역할 할당'), +('permissions.read', '권한 조회'), +('permissions.assign', '권한 할당'), + +-- 트렌드 키워드 관리 +('trends.read', '트렌드 키워드 조회'), +('trends.create', '트렌드 키워드 등록'), +('trends.update', '트렌드 키워드 수정'), +('trends.delete', '트렌드 키워드 삭제'), +('trends.analyze', '트렌드 분석'), + +-- 크롤링 관리 +('crawling.create', '크롤링 작업 생성'), +('crawling.read', '크롤링 결과 조회'), +('crawling.update', '크롤링 설정 수정'), +('crawling.delete', '크롤링 데이터 삭제'), +('crawling.execute', '크롤링 실행'), +('crawling.schedule', '크롤링 스케줄 관리'), + +-- 콘텐츠 생성 +('content.create', '콘텐츠 생성'), +('content.read', '콘텐츠 조회'), +('content.read.own', '본인 콘텐츠만 조회'), +('content.read.department', '부서 콘텐츠 조회'), +('content.read.all', '모든 콘텐츠 조회'), +('content.update', '콘텐츠 수정'), +('content.delete', '콘텐츠 삭제'), +('content.publish', '콘텐츠 발행'), +('content.approve', '콘텐츠 승인'), +('content.reject', '콘텐츠 거절'), + +-- AI 모델 관리 +('ai.models.read', 'AI 모델 조회'), +('ai.models.create', 'AI 모델 생성'), +('ai.models.update', 'AI 모델 수정'), +('ai.models.delete', 'AI 모델 삭제'), +('ai.models.train', 'AI 모델 학습'), +('ai.models.deploy', 'AI 모델 배포'), + +-- 워크플로우 관리 +('workflows.create', '워크플로우 생성'), +('workflows.read', '워크플로우 조회'), +('workflows.update', '워크플로우 수정'), +('workflows.delete', '워크플로우 삭제'), +('workflows.execute', '워크플로우 실행'), +('workflows.schedule', '워크플로우 스케줄링'), + +-- 캠페인 관리 +('campaigns.create', '캠페인 생성'), +('campaigns.read', '캠페인 조회'), +('campaigns.update', '캠페인 수정'), +('campaigns.delete', '캠페인 삭제'), +('campaigns.launch', '캠페인 시작'), +('campaigns.pause', '캠페인 일시정지'), + +-- 분석/리포트 +('analytics.read', '분석 데이터 조회'), +('analytics.export', '분석 데이터 내보내기'), +('reports.create', '보고서 생성'), +('reports.read', '보고서 조회'), +('reports.export', '보고서 내보내기'), + +-- 시스템 관리 +('system.settings.read', '시스템 설정 조회'), +('system.settings.update', '시스템 설정 수정'), +('system.logs.read', '시스템 로그 조회'), +('system.backup.create', '시스템 백업 생성'), +('system.backup.restore', '시스템 백업 복원'); + +-- 5. 시스템 공통 역할 +INSERT INTO `roles` (`organization_id`, `name`, `description`) VALUES + (NULL, 'SUPER_ADMIN', '최고 관리자 - 모든 권한'), + (NULL, 'SYSTEM_ADMIN', '시스템 관리자 - 시스템 설정 및 관리'), + (NULL, 'ORG_ADMIN', '조직 관리자 - 조직 내 모든 권한'), + (NULL, 'USER', '일반 사용자 - 기본 사용 권한'), + (NULL, 'GUEST', '게스트 - 제한된 조회 권한'); + +-- 6. icebang 전용 역할 +INSERT INTO `roles` (`organization_id`, `name`, `description`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'AI_ENGINEER', 'AI 엔지니어 - AI 모델 개발 및 최적화'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'DATA_SCIENTIST', '데이터 사이언티스트 - 데이터 분석 및 인사이트 도출'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'CRAWLING_ENGINEER', '크롤링 엔지니어 - 웹 크롤링 시스템 개발'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'CONTENT_CREATOR', '콘텐츠 크리에이터 - 바이럴 콘텐츠 제작'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'CONTENT_MANAGER', '콘텐츠 매니저 - 콘텐츠 기획 및 관리'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'WORKFLOW_ADMIN', '워크플로우 관리자 - 자동화 프로세스 관리'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'MARKETING_ANALYST', '마케팅 분석가 - 마케팅 성과 분석'), + ((SELECT id FROM organizations WHERE domain_name = 'icebang.site'), 'OPERATIONS_MANAGER', '운영 매니저 - 시스템 운영 및 모니터링'); + +-- 7. icebang 직원들 +INSERT INTO `users` (`name`, `email`, `password`, `status`) VALUES + ('김아이스', 'ice.kim@icebang.site', '$2a$10$encrypted_password_hash1', 'ACTIVE'), + ('박방방', 'bang.park@icebang.site', '$2a$10$encrypted_password_hash2', 'ACTIVE'), + ('이트렌드', 'trend.lee@icebang.site', '$2a$10$encrypted_password_hash3', 'ACTIVE'), + ('정바이럴', 'viral.jung@icebang.site', '$2a$10$encrypted_password_hash4', 'ACTIVE'), + ('최콘텐츠', 'content.choi@icebang.site', '$2a$10$encrypted_password_hash5', 'ACTIVE'), + ('홍크롤러', 'crawler.hong@icebang.site', '$2a$10$encrypted_password_hash6', 'ACTIVE'), + ('서데이터', 'data.seo@icebang.site', '$2a$10$encrypted_password_hash7', 'ACTIVE'), + ('윤워크플로', 'workflow.yoon@icebang.site', '$2a$10$encrypted_password_hash8', 'ACTIVE'), + ('시스템관리자', 'admin@icebang.site', '$2a$10$encrypted_password_hash0', 'ACTIVE'); + +-- 8. icebang 직원-조직 연결 +INSERT INTO `user_organizations` (`user_id`, `organization_id`, `position_id`, `department_id`, `employee_number`, `status`) VALUES +-- 김아이스 - CEO, 기획팀 +((SELECT id FROM users WHERE email = 'ice.kim@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = 'CEO' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = '기획팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'PLN25001', 'ACTIVE'), + +-- 박방방 - CTO, AI개발팀 +((SELECT id FROM users WHERE email = 'bang.park@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = 'CTO' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = 'AI개발팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'AI25001', 'ACTIVE'), + +-- 이트렌드 - 팀장, 데이터팀 +((SELECT id FROM users WHERE email = 'trend.lee@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = '팀장' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = '데이터팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'DAT25001', 'ACTIVE'), + +-- 정바이럴 - 팀장, 콘텐츠팀 +((SELECT id FROM users WHERE email = 'viral.jung@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = '팀장' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = '콘텐츠팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'CON25001', 'ACTIVE'), + +((SELECT id FROM users WHERE email = 'content.choi@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = '시니어' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = '콘텐츠팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'CON25002', 'ACTIVE'), + +-- 홍크롤러 - 시니어, AI개발팀 +((SELECT id FROM users WHERE email = 'crawler.hong@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = '시니어' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = 'AI개발팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'AI25002', 'ACTIVE'), + +-- 서데이터 - 시니어, 데이터팀 +((SELECT id FROM users WHERE email = 'data.seo@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = '시니어' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = '데이터팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'DAT25002', 'ACTIVE'), + +-- 윤워크플로 - 팀장, 운영팀 +((SELECT id FROM users WHERE email = 'workflow.yoon@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = '팀장' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = '운영팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'OPS25001', 'ACTIVE'), + +-- 시스템관리자 - CTO, 운영팀 +((SELECT id FROM users WHERE email = 'admin@icebang.site'), + (SELECT id FROM organizations WHERE domain_name = 'icebang.site'), + (SELECT id FROM positions WHERE title = 'CTO' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + (SELECT id FROM departments WHERE name = '운영팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + 'OPS25000', 'ACTIVE'); + +-- 9. 역할별 권한 할당 + +-- SUPER_ADMIN 모든 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'SUPER_ADMIN'), + id +FROM permissions; + +-- ORG_ADMIN 조직 내 모든 권한 (시스템 권한 제외) +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'ORG_ADMIN'), + id +FROM permissions +WHERE resource NOT LIKE 'system.%'; + +-- AI_ENGINEER 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'AI_ENGINEER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + id +FROM permissions +WHERE resource LIKE 'ai.%' + OR resource LIKE 'crawling.%' + OR resource LIKE 'workflows.%' + OR resource IN ('content.read', 'trends.read', 'analytics.read'); + +-- DATA_SCIENTIST 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'DATA_SCIENTIST' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + id +FROM permissions +WHERE resource LIKE 'trends.%' + OR resource LIKE 'analytics.%' + OR resource LIKE 'reports.%' + OR resource IN ('content.read', 'campaigns.read', 'crawling.read'); + +-- CONTENT_MANAGER 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'CONTENT_MANAGER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + id +FROM permissions +WHERE resource LIKE 'content.%' + OR resource LIKE 'campaigns.%' + OR resource LIKE 'trends.%' + OR resource LIKE 'analytics.%' + OR resource IN ('users.read.department'); + +-- WORKFLOW_ADMIN 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'WORKFLOW_ADMIN' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + id +FROM permissions +WHERE resource LIKE 'workflows.%' + OR resource LIKE 'ai.%' + OR resource LIKE 'crawling.%' + OR resource LIKE 'system.%' + OR resource IN ('content.read', 'trends.read', 'analytics.read'); + +-- 10. icebang 직원별 역할 할당 + +-- 김아이스(CEO) - ORG_ADMIN +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'ORG_ADMIN'), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'ice.kim@icebang.site'; + +-- 박방방(CTO) - AI_ENGINEER + WORKFLOW_ADMIN +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'AI_ENGINEER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'bang.park@icebang.site'; + +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'WORKFLOW_ADMIN' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'bang.park@icebang.site'; + +-- 정바이럴(콘텐츠팀장) - CONTENT_MANAGER +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'CONTENT_MANAGER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'viral.jung@icebang.site'; + +-- 이트렌드(데이터팀장) - DATA_SCIENTIST +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'DATA_SCIENTIST' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'icebang.site')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'trend.lee@icebang.site'; + +-- 시스템관리자 - SUPER_ADMIN +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'SUPER_ADMIN'), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'admin@icebang.site'; \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/02-insert-external-users.sql b/apps/user-service/src/main/resources/sql/02-insert-external-users.sql new file mode 100644 index 00000000..f4620bbd --- /dev/null +++ b/apps/user-service/src/main/resources/sql/02-insert-external-users.sql @@ -0,0 +1,243 @@ +-- B2B 테스트용 외부 회사 INSERT + +-- 1. 외부 테스트 회사들 +INSERT INTO `organizations` (`name`, `domain_name`) VALUES + ('테크이노베이션', 'techinnovation.co.kr'), + ('디지털솔루션', 'digitalsolution.com'), + ('크리에이티브웍스', 'creativeworks.net'); + +-- 2. 테크이노베이션 부서들 +INSERT INTO `departments` (`organization_id`, `name`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '개발팀'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '디자인팀'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '인사팀'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '마케팅팀'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '영업팀'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '재무팀'); + +-- 3. 디지털솔루션 부서들 +INSERT INTO `departments` (`organization_id`, `name`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '개발팀'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '기획팀'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '운영팀'); + +-- 4. 크리에이티브웍스 부서들 +INSERT INTO `departments` (`organization_id`, `name`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), '디자인팀'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), '마케팅팀'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), '제작팀'); + +-- 5. 테크이노베이션 직책들 +INSERT INTO `positions` (`organization_id`, `title`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '사원'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '주임'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '대리'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '과장'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '차장'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '부장'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), '이사'); + +-- 6. 디지털솔루션 직책들 +INSERT INTO `positions` (`organization_id`, `title`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '사원'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '선임'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '책임'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '수석'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '팀장'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), '본부장'); + +-- 7. 크리에이티브웍스 직책들 +INSERT INTO `positions` (`organization_id`, `title`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), '주니어'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), '시니어'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), '리드'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), '디렉터'); + +-- 8. 외부 회사별 커스텀 역할 + +-- 테크이노베이션 역할 +INSERT INTO `roles` (`organization_id`, `name`, `description`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), 'DEPT_MANAGER', '부서 관리자 - 부서 내 관리 권한'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), 'TEAM_LEAD', '팀장 - 팀원 관리 및 프로젝트 리드'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), 'SENIOR_DEV', '시니어 개발자 - 개발 관련 고급 권한'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), 'JUNIOR_DEV', '주니어 개발자 - 개발 관련 기본 권한'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), 'PROJECT_MANAGER', '프로젝트 매니저 - 프로젝트 관리 권한'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), 'DESIGNER', '디자이너 - 디자인 관련 권한'), + ((SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), 'HR_SPECIALIST', '인사 담당자 - 인사 관리 권한'); + +-- 디지털솔루션 역할 +INSERT INTO `roles` (`organization_id`, `name`, `description`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), 'TECH_LEAD', '기술 리드 - 기술 관련 총괄'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), 'PRODUCT_OWNER', '프로덕트 오너 - 제품 기획 관리'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), 'QA_ENGINEER', 'QA 엔지니어 - 품질 보증'), + ((SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), 'DEVOPS', 'DevOps 엔지니어 - 인프라 관리'); + +-- 크리에이티브웍스 역할 +INSERT INTO `roles` (`organization_id`, `name`, `description`) VALUES + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), 'CREATIVE_DIRECTOR', '크리에이티브 디렉터 - 창작 총괄'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), 'ART_DIRECTOR', '아트 디렉터 - 예술 감독'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), 'MOTION_DESIGNER', '모션 디자이너 - 영상/애니메이션'), + ((SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), 'COPYWRITER', '카피라이터 - 콘텐츠 작성'); + +-- 9. 외부 회사 테스트 사용자들 +INSERT INTO `users` (`name`, `email`, `password`, `status`) VALUES +-- 테크이노베이션 직원 +('김철수', 'chulsoo.kim@techinnovation.co.kr', '$2a$10$encrypted_password_hash11', 'ACTIVE'), +('이영희', 'younghee.lee@techinnovation.co.kr', '$2a$10$encrypted_password_hash12', 'ACTIVE'), +('박민수', 'minsu.park@techinnovation.co.kr', '$2a$10$encrypted_password_hash13', 'ACTIVE'), + +-- 디지털솔루션 직원 +('정수연', 'sooyeon.jung@digitalsolution.com', '$2a$10$encrypted_password_hash14', 'ACTIVE'), +('최현우', 'hyunwoo.choi@digitalsolution.com', '$2a$10$encrypted_password_hash15', 'ACTIVE'), + +-- 크리에이티브웍스 직원 +('홍지아', 'jia.hong@creativeworks.net', '$2a$10$encrypted_password_hash16', 'ACTIVE'); + +-- 10. 외부 회사 사용자-조직 연결 +INSERT INTO `user_organizations` (`user_id`, `organization_id`, `position_id`, `department_id`, `employee_number`, `status`) VALUES +-- 테크이노베이션 직원들 +-- 김철수 - 개발팀 과장 +((SELECT id FROM users WHERE email = 'chulsoo.kim@techinnovation.co.kr'), + (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), + (SELECT id FROM positions WHERE title = '과장' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + (SELECT id FROM departments WHERE name = '개발팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + 'DEV25001', 'ACTIVE'), + +-- 이영희 - 디자인팀 대리 +((SELECT id FROM users WHERE email = 'younghee.lee@techinnovation.co.kr'), + (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), + (SELECT id FROM positions WHERE title = '대리' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + (SELECT id FROM departments WHERE name = '디자인팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + 'DES25001', 'ACTIVE'), + +-- 박민수 - 인사팀 차장 +((SELECT id FROM users WHERE email = 'minsu.park@techinnovation.co.kr'), + (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr'), + (SELECT id FROM positions WHERE title = '차장' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + (SELECT id FROM departments WHERE name = '인사팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + 'HR25001', 'ACTIVE'), + +-- 디지털솔루션 직원들 +-- 정수연 - 개발팀 팀장 +((SELECT id FROM users WHERE email = 'sooyeon.jung@digitalsolution.com'), + (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), + (SELECT id FROM positions WHERE title = '팀장' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com')), + (SELECT id FROM departments WHERE name = '개발팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com')), + 'DEV25001', 'ACTIVE'), + +-- 최현우 - 기획팀 책임 +((SELECT id FROM users WHERE email = 'hyunwoo.choi@digitalsolution.com'), + (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com'), + (SELECT id FROM positions WHERE title = '책임' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com')), + (SELECT id FROM departments WHERE name = '기획팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com')), + 'PLN25001', 'ACTIVE'), + +-- 크리에이티브웍스 직원 +-- 홍지아 - 디자인팀 리드 +((SELECT id FROM users WHERE email = 'jia.hong@creativeworks.net'), + (SELECT id FROM organizations WHERE domain_name = 'creativeworks.net'), + (SELECT id FROM positions WHERE title = '리드' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'creativeworks.net')), + (SELECT id FROM departments WHERE name = '디자인팀' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'creativeworks.net')), + 'DES25001', 'ACTIVE'); + +-- 11. 외부 회사 사용자별 역할 할당 + +-- 테크이노베이션 +-- 김철수에게 DEPT_MANAGER 역할 +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'DEPT_MANAGER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'chulsoo.kim@techinnovation.co.kr'; + +-- 이영희에게 DESIGNER 역할 +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'DESIGNER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'younghee.lee@techinnovation.co.kr'; + +-- 박민수에게 HR_SPECIALIST 역할 +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'HR_SPECIALIST' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'minsu.park@techinnovation.co.kr'; + +-- 디지털솔루션 +-- 정수연에게 TECH_LEAD 역할 +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'TECH_LEAD' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'sooyeon.jung@digitalsolution.com'; + +-- 최현우에게 PRODUCT_OWNER 역할 +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'PRODUCT_OWNER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'hyunwoo.choi@digitalsolution.com'; + +-- 크리에이티브웍스 +-- 홍지아에게 CREATIVE_DIRECTOR 역할 +INSERT INTO `user_roles` (`role_id`, `user_organization_id`) +SELECT + (SELECT id FROM roles WHERE name = 'CREATIVE_DIRECTOR' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'creativeworks.net')), + uo.id +FROM user_organizations uo + JOIN users u ON u.id = uo.user_id +WHERE u.email = 'jia.hong@creativeworks.net'; + +-- 12. 외부 회사 역할별 기본 권한 할당 (샘플) + +-- DEPT_MANAGER 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'DEPT_MANAGER' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'techinnovation.co.kr')), + id +FROM permissions +WHERE resource IN ( + 'users.read.department', 'users.update', 'users.invite', + 'departments.read', 'departments.manage', + 'content.create', 'content.read.all', 'content.update', 'content.approve', + 'campaigns.create', 'campaigns.read', 'campaigns.update', + 'analytics.read', 'reports.read' + ); + +-- TECH_LEAD 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'TECH_LEAD' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'digitalsolution.com')), + id +FROM permissions +WHERE resource LIKE 'ai.%' + OR resource LIKE 'workflows.%' + OR resource IN ( + 'users.read.department', + 'content.read', 'content.create', + 'trends.read', 'analytics.read' + ); + +-- CREATIVE_DIRECTOR 권한 +INSERT INTO `role_permissions` (`role_id`, `permission_id`) +SELECT + (SELECT id FROM roles WHERE name = 'CREATIVE_DIRECTOR' AND organization_id = (SELECT id FROM organizations WHERE domain_name = 'creativeworks.net')), + id +FROM permissions +WHERE resource LIKE 'content.%' + OR resource LIKE 'campaigns.%' + OR resource IN ( + 'users.read.organization', + 'trends.read', 'analytics.read', 'reports.create' + ); \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/schema.sql b/apps/user-service/src/main/resources/sql/schema.sql new file mode 100644 index 00000000..e2a9a917 --- /dev/null +++ b/apps/user-service/src/main/resources/sql/schema.sql @@ -0,0 +1,256 @@ +-- MariaDB 최적화된 스키마 (소문자, VARCHAR 크기 지정) +CREATE TABLE IF NOT EXISTS `permissions` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `resource` varchar(100) NULL, + `description` varchar(255) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_active` boolean DEFAULT TRUE, + `updated_by` bigint unsigned NULL, + `created_by` bigint unsigned NULL, + PRIMARY KEY (`id`) +); + +CREATE TABLE IF NOT EXISTS `organizations` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(150) NULL, + `domain_name` varchar(100) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +CREATE TABLE IF NOT EXISTS `roles` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `organization_id` bigint unsigned NULL, + `name` varchar(100) NULL, + `description` varchar(500) NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_organizations_to_roles` FOREIGN KEY (`organization_id`) + REFERENCES `organizations` (`id`) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS `users` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NULL, + `email` varchar(100) NULL, + `password` varchar(255) NULL, + `status` varchar(20) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +); + +CREATE TABLE IF NOT EXISTS `departments` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `organization_id` bigint unsigned NOT NULL, + `name` varchar(100) NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_organizations_to_departments` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) +); + +CREATE TABLE IF NOT EXISTS `positions` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `organization_id` bigint unsigned NOT NULL, + `title` varchar(100) NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_organizations_to_positions` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`) +); + +CREATE TABLE IF NOT EXISTS `user_organizations` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint unsigned NOT NULL, + `organization_id` bigint unsigned NOT NULL, + `position_id` bigint unsigned NOT NULL, + `department_id` bigint unsigned NOT NULL, + `employee_number` varchar(50) NULL, + `status` varchar(20) NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + CONSTRAINT `fk_users_to_user_organizations` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), + CONSTRAINT `fk_organizations_to_user_organizations` FOREIGN KEY (`organization_id`) REFERENCES `organizations` (`id`), + CONSTRAINT `fk_positions_to_user_organizations` FOREIGN KEY (`position_id`) REFERENCES `positions` (`id`), + CONSTRAINT `fk_departments_to_user_organizations` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) +); + +CREATE TABLE IF NOT EXISTS `role_permissions` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `role_id` bigint unsigned NOT NULL, + `permission_id` int unsigned NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_roles_to_role_permissions` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`), + CONSTRAINT `fk_permissions_to_role_permissions` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`), + UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`) +); + +CREATE TABLE IF NOT EXISTS `user_roles` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `role_id` bigint unsigned NOT NULL, + `user_organization_id` bigint unsigned NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_roles_to_user_roles` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`), + CONSTRAINT `fk_user_organizations_to_user_roles` FOREIGN KEY (`user_organization_id`) REFERENCES `user_organizations` (`id`), + UNIQUE KEY `uk_user_role` (`role_id`, `user_organization_id`) +); + +-- 성능 최적화를 위한 인덱스 +CREATE INDEX IF NOT EXISTS + `idx_users_email` ON `users` (`email`); +CREATE INDEX IF NOT EXISTS + `idx_users_status` ON `users` (`status`); +CREATE INDEX IF NOT EXISTS + `idx_user_organizations_user` ON `user_organizations` (`user_id`); +CREATE INDEX IF NOT EXISTS + `idx_user_organizations_org` ON `user_organizations` (`organization_id`); +CREATE INDEX IF NOT EXISTS + `idx_user_organizations_status` ON `user_organizations` (`status`); +CREATE INDEX IF NOT EXISTS + `idx_roles_org` ON `roles` (`organization_id`); +CREATE INDEX IF NOT EXISTS + `idx_permissions_resource` ON `permissions` (`resource`); +CREATE INDEX IF NOT EXISTS + `idx_permissions_active` ON `permissions` (`is_active`); + + + +CREATE TABLE IF NOT EXISTS `workflows` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL UNIQUE, + `description` text NULL, + `is_enabled` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint unsigned NULL, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint unsigned NULL, + PRIMARY KEY (`id`) + ); + +CREATE TABLE IF NOT EXISTS `schedules` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `workflow_id` bigint unsigned NOT NULL, + `cron_expression` varchar(50) NULL, + `parameters` json NULL, + `is_active` boolean DEFAULT TRUE, + `last_run_status` varchar(20) NULL, + `last_run_at` timestamp NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint unsigned NULL, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint unsigned NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_schedules_to_workflows` FOREIGN KEY (`workflow_id`) REFERENCES `workflows` (`id`) + ); + +CREATE TABLE IF NOT EXISTS `jobs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL UNIQUE, + `description` text NULL, + `is_enabled` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint unsigned NULL, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint unsigned NULL, + PRIMARY KEY (`id`) + ); + +CREATE TABLE IF NOT EXISTS `tasks` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(100) NOT NULL UNIQUE, + `type` varchar(50) NULL, + `parameters` json NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ); + +CREATE TABLE IF NOT EXISTS `workflow_jobs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `workflow_id` bigint unsigned NOT NULL, + `job_id` bigint unsigned NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_workflow_jobs_to_workflows` FOREIGN KEY (`workflow_id`) REFERENCES `workflows` (`id`), + CONSTRAINT `fk_workflow_jobs_to_jobs` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`), + UNIQUE KEY `uk_workflow_job` (`workflow_id`, `job_id`) + ); + +CREATE TABLE IF NOT EXISTS `job_tasks` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `job_id` bigint unsigned NOT NULL, + `task_id` bigint unsigned NOT NULL, + `execution_order` int NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_job_tasks_to_jobs` FOREIGN KEY (`job_id`) REFERENCES `jobs` (`id`), + CONSTRAINT `fk_job_tasks_to_tasks` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`), + UNIQUE KEY `uk_job_task` (`job_id`, `task_id`) + ); + +CREATE TABLE IF NOT EXISTS `execution_logs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `execution_type` varchar(20) NULL COMMENT 'task, schedule, job, workflow', + `source_id` bigint unsigned NULL COMMENT '모든 데이터에 대한 ID ex: job_id, schedule_id, task_id, ...', + `log_level` varchar(20) NULL, + `executed_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `log_message` text NULL, + `trace_id` char(36) NULL, + `config_snapshot` json NULL, + PRIMARY KEY (`id`), + INDEX `idx_source_id_type` (`source_id`, `execution_type`) + ); + +CREATE TABLE IF NOT EXISTS `task_io_data` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `trace_id` char(36) NULL, + `io_type` varchar(10) NULL COMMENT 'INPUT, OUTPUT', + `name` varchar(100) NULL, + `data_type` varchar(50) NULL, + `data_value` json NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_trace_id` (`trace_id`) + ); + +CREATE TABLE IF NOT EXISTS `configs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `target_type` varchar(50) NULL COMMENT 'user, job, workflow', + `target_id` bigint unsigned NULL, + `version` int NULL, + `json` json NULL, + `is_active` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint unsigned NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_config_target` (`target_type`, `target_id`) + ); + +CREATE TABLE IF NOT EXISTS `categories` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(100) NULL, + `description` text NULL, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ); + +CREATE TABLE IF NOT EXISTS `user_configs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint unsigned NOT NULL, + `type` varchar(50) NULL, + `name` varchar(100) NULL, + `json` json NULL, + `is_active` boolean DEFAULT TRUE, + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) + ); + +-- 인덱스 추가 (성능 최적화) +CREATE INDEX IF NOT EXISTS `idx_schedules_workflow` ON `schedules` (`workflow_id`); +CREATE INDEX IF NOT EXISTS `idx_jobs_enabled` ON `jobs` (`is_enabled`); +CREATE INDEX IF NOT EXISTS `idx_tasks_type` ON `tasks` (`type`); +CREATE INDEX IF NOT EXISTS `idx_workflows_enabled` ON `workflows` (`is_enabled`); +CREATE UNIQUE INDEX IF NOT EXISTS `uk_schedules_workflow` ON `schedules` (`workflow_id`); +CREATE UNIQUE INDEX IF NOT EXISTS `uk_job_name` ON `jobs` (`name`); +CREATE UNIQUE INDEX IF NOT EXISTS `uk_task_name` ON `tasks` (`name`); +CREATE UNIQUE INDEX IF NOT EXISTS `uk_workflows_name` ON `workflows` (`name`); +CREATE INDEX IF NOT EXISTS `idx_user_configs_user` ON `user_configs` (`user_id`); \ No newline at end of file diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/DatabaseConnectionTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/DatabaseConnectionTest.java index a3dd2e77..e744873b 100644 --- a/apps/user-service/src/test/java/com/gltkorea/icebang/DatabaseConnectionTest.java +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/DatabaseConnectionTest.java @@ -1,81 +1,81 @@ -package com.gltkorea.icebang; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.Optional; - -import javax.sql.DataSource; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.transaction.annotation.Transactional; - -import com.gltkorea.icebang.dto.UserDto; -import com.gltkorea.icebang.mapper.UserMapper; - -@SpringBootTest -@Import(TestcontainersConfiguration.class) -@AutoConfigureTestDatabase(replace = Replace.NONE) -@ActiveProfiles("test") // application-test.yml 설정을 활성화 -@Transactional // 테스트 후 데이터 롤백 -@Sql( - scripts = {"classpath:sql/create-schema.sql", "classpath:sql/insert-user-data.sql"}, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) -class DatabaseConnectionTest { - - @Autowired private DataSource dataSource; - - @Autowired private UserMapper userMapper; // JPA Repository 대신 MyBatis Mapper를 주입 - - @Test - @DisplayName("DataSource를 통해 DB 커넥션을 성공적으로 얻을 수 있다.") - void canGetDatabaseConnection() { - try (Connection connection = dataSource.getConnection()) { - assertThat(connection).isNotNull(); - assertThat(connection.isValid(1)).isTrue(); - System.out.println("DB Connection successful: " + connection.getMetaData().getURL()); - } catch (SQLException e) { - org.junit.jupiter.api.Assertions.fail("Failed to get database connection", e); - } - } - - @Test - @DisplayName("MyBatis Mapper를 통해 '홍길동' 사용자를 이메일로 조회") - void findUserByEmailWithMyBatis() { - // given - String testEmail = "hong.gildong@example.com"; - - // when - Optional foundUser = userMapper.findByEmail(testEmail); - - // then - // 사용자가 존재하고, 이름이 '홍길동'인지 확인 - assertThat(foundUser).isPresent(); - assertThat(foundUser.get().getName()).isEqualTo("홍길동"); - System.out.println("Successfully found user with MyBatis: " + foundUser.get().getName()); - } - - @Test - @DisplayName("샘플 데이터가 올바르게 삽입되었는지 확인") - void verifyAllSampleDataInserted() { - // 사용자 데이터 확인 - Optional hong = userMapper.findByEmail("hong.gildong@example.com"); - assertThat(hong).isPresent(); - assertThat(hong.get().getName()).isEqualTo("홍길동"); - - Optional kim = userMapper.findByEmail("kim.chulsu@example.com"); - assertThat(kim).isPresent(); - assertThat(kim.get().getName()).isEqualTo("김철수"); - - System.out.println("샘플 데이터 삽입 성공 - 홍길동, 김철수 확인"); - } -} +// package com.gltkorea.icebang; +// +// import static org.assertj.core.api.Assertions.assertThat; +// +// import java.sql.Connection; +// import java.sql.SQLException; +// import java.util.Optional; +// +// import javax.sql.DataSource; +// +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.context.annotation.Import; +// import org.springframework.test.context.ActiveProfiles; +// import org.springframework.test.context.jdbc.Sql; +// import org.springframework.transaction.annotation.Transactional; +// +// import com.gltkorea.icebang.dto.UserDto; +// import com.gltkorea.icebang.mapper.UserMapper; +// +// @SpringBootTest +// @Import(TestcontainersConfiguration.class) +// @AutoConfigureTestDatabase(replace = Replace.NONE) +// @ActiveProfiles("test") // application-test-unit.yml 설정을 활성화 +// @Transactional // 테스트 후 데이터 롤백 +// @Sql( +// scripts = {"classpath:sql/create-schema.sql", "classpath:sql/insert-user-data.sql"}, +// executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +// class DatabaseConnectionTest { +// +// @Autowired private DataSource dataSource; +// +// @Autowired private UserMapper userMapper; // JPA Repository 대신 MyBatis Mapper를 주입 +// +// @Test +// @DisplayName("DataSource를 통해 DB 커넥션을 성공적으로 얻을 수 있다.") +// void canGetDatabaseConnection() { +// try (Connection connection = dataSource.getConnection()) { +// assertThat(connection).isNotNull(); +// assertThat(connection.isValid(1)).isTrue(); +// System.out.println("DB Connection successful: " + connection.getMetaData().getURL()); +// } catch (SQLException e) { +// org.junit.jupiter.api.Assertions.fail("Failed to get database connection", e); +// } +// } +// +// @Test +// @DisplayName("MyBatis Mapper를 통해 '홍길동' 사용자를 이메일로 조회") +// void findUserByEmailWithMyBatis() { +// // given +// String testEmail = "hong.gildong@example.com"; +// +// // when +// Optional foundUser = userMapper.findByEmail(testEmail); +// +// // then +// // 사용자가 존재하고, 이름이 '홍길동'인지 확인 +// assertThat(foundUser).isPresent(); +// assertThat(foundUser.get().getName()).isEqualTo("홍길동"); +// System.out.println("Successfully found user with MyBatis: " + foundUser.get().getName()); +// } +// +// @Test +// @DisplayName("샘플 데이터가 올바르게 삽입되었는지 확인") +// void verifyAllSampleDataInserted() { +// // 사용자 데이터 확인 +// Optional hong = userMapper.findByEmail("hong.gildong@example.com"); +// assertThat(hong).isPresent(); +// assertThat(hong.get().getName()).isEqualTo("홍길동"); +// +// Optional kim = userMapper.findByEmail("kim.chulsu@example.com"); +// assertThat(kim).isPresent(); +// assertThat(kim.get().getName()).isEqualTo("김철수"); +// +// System.out.println("샘플 데이터 삽입 성공 - 홍길동, 김철수 확인"); +// } +// } diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/annotation/E2eTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/annotation/E2eTest.java new file mode 100644 index 00000000..43290a4a --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/annotation/E2eTest.java @@ -0,0 +1,15 @@ +package com.gltkorea.icebang.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.Tag; +import org.springframework.test.context.ActiveProfiles; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Tag("e2e") +@ActiveProfiles("test-e2e") +public @interface E2eTest {} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/annotation/UnitTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/annotation/UnitTest.java new file mode 100644 index 00000000..1927475a --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/annotation/UnitTest.java @@ -0,0 +1,15 @@ +package com.gltkorea.icebang.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.Tag; +import org.springframework.test.context.ActiveProfiles; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Tag("unit") +@ActiveProfiles("test-unit") +public @interface UnitTest {} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/config/E2eTestConfiguration.java b/apps/user-service/src/test/java/com/gltkorea/icebang/config/E2eTestConfiguration.java new file mode 100644 index 00000000..054360b1 --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/config/E2eTestConfiguration.java @@ -0,0 +1,38 @@ +package com.gltkorea.icebang.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MariaDBContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class E2eTestConfiguration { + + @Bean + @ServiceConnection + MariaDBContainer mariadbContainer() { + return new MariaDBContainer<>("mariadb:11.4") + .withDatabaseName("pre_process") + .withUsername("mariadb") + .withPassword("qwer1234"); + } + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry, MariaDBContainer mariadb) { + // MariaDB 연결 설정 + registry.add("spring.datasource.url", mariadb::getJdbcUrl); + registry.add("spring.datasource.username", mariadb::getUsername); + registry.add("spring.datasource.password", mariadb::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver"); + + // HikariCP 설정 + registry.add("spring.hikari.connection-timeout", () -> "30000"); + registry.add("spring.hikari.idle-timeout", () -> "600000"); + registry.add("spring.hikari.max-lifetime", () -> "1800000"); + registry.add("spring.hikari.maximum-pool-size", () -> "10"); + registry.add("spring.hikari.minimum-idle", () -> "5"); + registry.add("spring.hikari.pool-name", () -> "HikariCP-E2E"); + } +} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/controller/TestController.java b/apps/user-service/src/test/java/com/gltkorea/icebang/controller/TestController.java new file mode 100644 index 00000000..c29707ce --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/controller/TestController.java @@ -0,0 +1,15 @@ +package com.gltkorea.icebang.controller; + +import org.springframework.boot.test.context.TestComponent; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@TestComponent +@RestController +public class TestController { + + @GetMapping("/api/health") + public String health() { + return "OK"; + } +} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupport.java b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupport.java new file mode 100644 index 00000000..ddb3afd9 --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupport.java @@ -0,0 +1,28 @@ +package com.gltkorea.icebang.support; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; + +import com.gltkorea.icebang.annotation.E2eTest; +import com.gltkorea.icebang.config.E2eTestConfiguration; + +@Import(E2eTestConfiguration.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@E2eTest +public abstract class E2eTestSupport { + + @LocalServerPort protected int port; + + @Autowired protected TestRestTemplate restTemplate; + + protected String getBaseUrl() { + return "http://localhost:" + port; + } + + protected String getApiUrl(String path) { + return getBaseUrl() + "/api" + path; + } +} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java new file mode 100644 index 00000000..bad5a2ba --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/support/E2eTestSupportTest.java @@ -0,0 +1,29 @@ +package com.gltkorea.icebang.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class E2eTestSupportTest extends E2eTestSupport { + + @Test + void shouldStartWithRandomPort() { + // 포트가 제대로 할당되었는지 확인 + assertThat(port).isGreaterThan(0); + assertThat(getBaseUrl()).startsWith("http://localhost:"); + assertThat(getApiUrl("/test")).contains("/api/test"); + } + + @Test + void shouldHaveRestTemplate() { + // RestTemplate이 주입되었는지 확인 + assertThat(restTemplate).isNotNull(); + } + + @Test + void shouldConnectToMariaDBContainer() { + // 실제 DB 연결 확인 + String response = restTemplate.getForObject(getApiUrl("/health"), String.class); + // health check endpoint가 있다면 사용, 없으면 간단한 컨트롤러 만들어서 테스트 + } +} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/support/UnitTestSupport.java b/apps/user-service/src/test/java/com/gltkorea/icebang/support/UnitTestSupport.java new file mode 100644 index 00000000..88c4315e --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/support/UnitTestSupport.java @@ -0,0 +1,15 @@ +package com.gltkorea.icebang.support; + +import org.springframework.boot.test.context.SpringBootTest; + +import com.gltkorea.icebang.annotation.UnitTest; + +@SpringBootTest +@UnitTest +public abstract class UnitTestSupport { + + // 공통 유틸리티 메서드들 + protected void assertCommonValidation() { + // 공통 검증 로직 + } +} diff --git a/apps/user-service/src/test/java/com/gltkorea/icebang/support/UnitTestSupportTest.java b/apps/user-service/src/test/java/com/gltkorea/icebang/support/UnitTestSupportTest.java new file mode 100644 index 00000000..232a2c1f --- /dev/null +++ b/apps/user-service/src/test/java/com/gltkorea/icebang/support/UnitTestSupportTest.java @@ -0,0 +1,41 @@ +package com.gltkorea.icebang.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class UnitTestSupportTest extends UnitTestSupport { + + @Autowired private DataSource dataSource; + + @Test + void shouldUseH2DatabaseWithMariaDBMode() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + String url = connection.getMetaData().getURL(); + assertThat(url).contains("h2:mem:testdb"); + + // MariaDB 모드 확인 + Statement stmt = connection.createStatement(); + ResultSet rs = + stmt.executeQuery( + "SELECT SETTING_VALUE FROM INFORMATION_SCHEMA.SETTINGS WHERE SETTING_NAME = 'MODE'"); + if (rs.next()) { + assertThat(rs.getString(1)).isEqualTo("MariaDB"); + } + } + } + + @Test + void shouldLoadApplicationContext() { + // Spring Context 로딩 확인 + assertThat(dataSource).isNotNull(); + } +} diff --git a/apps/user-service/src/test/resources/sql/create-schema.sql b/apps/user-service/src/test/resources/sql/create-schema.sql deleted file mode 100644 index 115603f8..00000000 --- a/apps/user-service/src/test/resources/sql/create-schema.sql +++ /dev/null @@ -1,99 +0,0 @@ --- 테이블 DROP (재생성을 위해 기존 테이블을 삭제) -DROP TABLE IF EXISTS "ROLE_PERMISSION"; -DROP TABLE IF EXISTS "USER_ROLE"; -DROP TABLE IF EXISTS "PERMISSION"; -DROP TABLE IF EXISTS "ROLE"; -DROP TABLE IF EXISTS "USER_GROUP_INFO"; -DROP TABLE IF EXISTS "GROUP_INFO"; -DROP TABLE IF EXISTS "USER"; - - --- 사용자 정보 (외부 노출 가능성 높음 -> UUID) -CREATE TABLE "USER" ( - "user_id" VARCHAR(36) NOT NULL, - "name" VARCHAR(100) NULL, - "email" VARCHAR(255) NULL UNIQUE, - "password" VARCHAR(255) NULL, - "phone_number" VARCHAR(50) NULL, - "fax_number" VARCHAR(50) NULL, - "zip_code" VARCHAR(20) NULL, - "main_address" VARCHAR(255) NULL, - "detail_address" VARCHAR(255) NULL, - "recommender_id" VARCHAR(36) NULL, - "resident_number" VARCHAR(100) NULL, - "corporate_number" VARCHAR(100) NULL, - "business_number" VARCHAR(100) NULL, - "type" VARCHAR(50) NULL, - "department" VARCHAR(100) NULL, - "job_title" VARCHAR(50) NULL, - "grade" VARCHAR(50) NULL, - "status" VARCHAR(50) NULL, - "joined_at" TIMESTAMP NULL, - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY ("user_id") -); - - -CREATE TABLE "GROUP_INFO" ( - "group_info_id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "name" VARCHAR(255) NULL, - "description" TEXT NULL, - "status" VARCHAR(50) NULL, - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE "ROLE" ( - "role_id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "name" VARCHAR(50) NULL, - "code" VARCHAR(50) NULL UNIQUE, - "description" VARCHAR(255) NULL, - "status" VARCHAR(50) NULL, - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE "PERMISSION" ( - "permission_id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "name" VARCHAR(50) NULL, - "code" VARCHAR(50) NULL UNIQUE, - "resource" VARCHAR(50) NULL, - "action" VARCHAR(50) NULL, - "description" VARCHAR(255) NULL, - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE "USER_GROUP_INFO" ( - "user_group_info_id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "user_id" VARCHAR(36) NOT NULL, -- USER 테이블 참조 - "group_info_id" BIGINT NOT NULL, -- GROUP_INFO 테이블 참조 - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY ("user_id") REFERENCES "USER" ("user_id"), - FOREIGN KEY ("group_info_id") REFERENCES "GROUP_INFO" ("group_info_id"), - UNIQUE ("user_id", "group_info_id") -); - -CREATE TABLE "USER_ROLE" ( - "user_role_id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "user_id" VARCHAR(36) NOT NULL, -- USER 테이블 참조 - "role_id" BIGINT NOT NULL, -- ROLE 테이블 참조 - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY ("user_id") REFERENCES "USER" ("user_id"), - FOREIGN KEY ("role_id") REFERENCES "ROLE" ("role_id"), - UNIQUE ("user_id", "role_id") -); - -CREATE TABLE "ROLE_PERMISSION" ( - "role_permission_id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "role_id" BIGINT NOT NULL, -- ROLE 테이블 참조 - "permission_id" BIGINT NOT NULL, -- PERMISSION 테이블 참조 - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY ("role_id") REFERENCES "ROLE" ("role_id"), - FOREIGN KEY ("permission_id") REFERENCES "PERMISSION" ("permission_id"), - UNIQUE ("role_id", "permission_id") -); diff --git a/apps/user-service/src/test/resources/sql/insert-user-data.sql b/apps/user-service/src/test/resources/sql/insert-user-data.sql deleted file mode 100644 index 95a24551..00000000 --- a/apps/user-service/src/test/resources/sql/insert-user-data.sql +++ /dev/null @@ -1,38 +0,0 @@ -INSERT INTO "USER" ("user_id", "name", "email", "password", "phone_number", "type", "status", "joined_at") -VALUES - ('86b2414f-8e4d-4c3e-953e-1b6c7003c271', '홍길동', 'hong.gildong@example.com', 'hashed_password_1', '010-1234-5678', 'INDIVIDUAL', 'ACTIVE', NOW()), - ('92d04a8b-185d-4f1b-85d1-9650d99d1234', '김철수', 'kim.chulsu@example.com', 'hashed_1b590e829a28', '010-9876-5432', 'INDIVIDUAL', 'ACTIVE', NOW()); - -INSERT INTO "GROUP_INFO" ("name", "description", "status") -VALUES - ('개발팀', '애플리케이션 개발 그룹', 'ACTIVE'), -- ID 1로 생성됨 - ('기획팀', '프로젝트 기획 그룹', 'ACTIVE'); -- ID 2로 생성됨 - -INSERT INTO "USER_GROUP_INFO" ("user_id", "group_info_id") -VALUES - ('86b2414f-8e4d-4c3e-953e-1b6c7003c271', 1), -- 홍길동 -> 개발팀 - ('92d04a8b-185d-4f1b-85d1-9650d99d1234', 2); -- 김철수 -> 기획팀 - -INSERT INTO "ROLE" ("name", "code", "description", "status") -VALUES - ('관리자', 'ADMIN', '모든 권한을 가진 역할', 'ACTIVE'), -- ID 1로 생성됨 - ('일반 사용자', 'USER', '기본 권한을 가진 역할', 'ACTIVE'); -- ID 2로 생성됨 - -INSERT INTO "PERMISSION" ("name", "code", "resource", "action", "description") -VALUES - ('사용자 정보 읽기', 'USER_READ', 'USER', 'READ', '사용자 정보 조회 권한'), -- ID 1로 생성됨 - ('사용자 정보 수정', 'USER_WRITE', 'USER', 'WRITE', '사용자 정보 수정 권한'), -- ID 2로 생성됨 - ('로그인', 'AUTH_LOGIN', 'AUTH', 'LOGIN', '로그인 권한'); -- ID 3으로 생성됨 - -INSERT INTO "USER_ROLE" ("user_id", "role_id") -VALUES - ('86b2414f-8e4d-4c3e-953e-1b6c7003c271', 1), -- 홍길동 -> 관리자 - ('92d04a8b-185d-4f1b-85d1-9650d99d1234', 2); -- 김철수 -> 일반 사용자 - -INSERT INTO "ROLE_PERMISSION" ("role_id", "permission_id") -VALUES - (1, 1), -- 관리자 -> 사용자 정보 읽기 - (1, 2), -- 관리자 -> 사용자 정보 수정 - (1, 3), -- 관리자 -> 로그인 - (2, 1), -- 일반 사용자 -> 사용자 정보 읽기 - (2, 3); -- 일반 사용자 -> 로그인 \ No newline at end of file diff --git a/docker/local/docker-compose.yml b/docker/local/docker-compose.yml index 62dabdd0..c0bf14fd 100644 --- a/docker/local/docker-compose.yml +++ b/docker/local/docker-compose.yml @@ -1,39 +1,37 @@ version: '3.8' services: - postgres: - image: postgres:15 - container_name: postgres_db + mariadb: + image: mariadb:11.4 + container_name: mariadb_db restart: unless-stopped environment: - POSTGRES_DB: pre_process - POSTGRES_USER: postgres - POSTGRES_PASSWORD: qwer1234 + MYSQL_ROOT_PASSWORD: qwer1234 + MYSQL_DATABASE: pre_process + MYSQL_USER: mariadb + MYSQL_PASSWORD: qwer1234 ports: - - "5432:5432" + - "3306:3306" volumes: - - postgres_data:/var/lib/postgresql/data - - ./init-scripts:/docker-entrypoint-initdb.d + - mariadb_data:/var/lib/mysql healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mariadb", "-pqwer1234"] interval: 10s timeout: 5s retries: 5 - pgadmin: - image: dpage/pgadmin4:latest - container_name: pgadmin + phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + container_name: phpmyadmin restart: unless-stopped environment: - PGADMIN_DEFAULT_EMAIL: admin@example.com - PGADMIN_DEFAULT_PASSWORD: qwer1234 + PMA_HOST: mariadb + PMA_USER: root + PMA_PASSWORD: qwer1234 ports: - "8888:80" - volumes: - - pgadmin_data:/var/lib/pgadmin depends_on: - - postgres + - mariadb volumes: - postgres_data: - pgadmin_data: \ No newline at end of file + mariadb_data: \ No newline at end of file