diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..15e0449 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,104 @@ +name: Check + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + frontend-lint: + name: "Frontend: Lint" + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run lint + + frontend-format: + name: "Frontend: Format" + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run format:check + + frontend-typecheck: + name: "Frontend: Type Check" + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run type-check + + backend-lint: + name: "Backend: Lint" + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --extra dev + - run: uv run ruff check . + + backend-format: + name: "Backend: Format" + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --extra dev + - run: uv run ruff format --check . + + backend-typecheck: + name: "Backend: Type Check" + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --extra dev + - run: uv run mypy . + + backend-test: + name: "Backend: Test" + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --extra test + - run: uv run pytest diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6c03652..c56dfd6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,18 +1,19 @@ import enum -from typing import Any, Optional +from functools import lru_cache +from typing import Any from pydantic import PostgresDsn, ValidationInfo, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -class AppEnvironment(str, enum.Enum): +class AppEnvironment(enum.StrEnum): DEVELOP = "development" PRODUCTION = "production" class Settings(BaseSettings): # 明示的に環境変数を読み込む(Pydantic_v2以降) - model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") ENVIRONMENT: AppEnvironment @@ -25,7 +26,7 @@ class Settings(BaseSettings): POSTGRES_PASSWORD: str POSTGRES_DB: str POSTGRES_PORT: str - SQLALCHEMY_DATABASE_URI: Optional[str] = None + SQLALCHEMY_DATABASE_URI: str | None = None # Kubernetes設定 K8S_NAMESPACE: str = "default" @@ -46,7 +47,7 @@ class Settings(BaseSettings): ANTHROPIC_API_KEY: str = "" @field_validator("SQLALCHEMY_DATABASE_URI", mode="after") - def assemble_db_connection(cls, v: Optional[str], values: ValidationInfo) -> Any: + def assemble_db_connection(cls, v: str | None, values: ValidationInfo) -> Any: if isinstance(v, str): return v @@ -56,10 +57,12 @@ def assemble_db_connection(cls, v: Optional[str], values: ValidationInfo) -> Any username=values.data.get("POSTGRES_USER"), password=values.data.get("POSTGRES_PASSWORD"), host=values.data.get("POSTGRES_SERVER"), - port=int(values.data.get("POSTGRES_PORT")), + port=int(values.data.get("POSTGRES_PORT", "5432")), path=f"{values.data.get('POSTGRES_DB') or ''}", ) ) -settings = Settings() \ No newline at end of file +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index 04bf337..aedde1d 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -12,9 +12,7 @@ class ProposalNotFoundError(Exception): def __init__(self, job_id: str, proposal_index: int) -> None: self.job_id = job_id self.proposal_index = proposal_index - super().__init__( - f"Proposal not found: job={job_id}, index={proposal_index}" - ) + super().__init__(f"Proposal not found: job={job_id}, index={proposal_index}") class K8sServiceError(Exception): @@ -36,6 +34,4 @@ def __init__(self, job_id: str, current_state: str, expected_state: str) -> None self.job_id = job_id self.current_state = current_state self.expected_state = expected_state - super().__init__( - f"Job {job_id} is in state '{current_state}', expected '{expected_state}'" - ) + super().__init__(f"Job {job_id} is in state '{current_state}', expected '{expected_state}'") diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index cf2abfc..2643ae0 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -3,7 +3,8 @@ from fastapi import Request from fastapi.responses import JSONResponse -from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response from app.core.exceptions import ( ArtifactNotFoundError, @@ -19,7 +20,7 @@ class ErrorHandlerMiddleware(BaseHTTPMiddleware): """Global error handler for uncaught exceptions.""" - async def dispatch(self, request: Request, call_next): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: try: response = await call_next(request) return response @@ -38,7 +39,11 @@ async def dispatch(self, request: Request, call_next): content={"detail": "Kubernetes service unavailable"}, ) except Exception: - logger.exception("Unhandled exception during request %s %s", request.method, request.url.path) + logger.exception( + "Unhandled exception during request %s %s", + request.method, + request.url.path, + ) return JSONResponse( status_code=500, content={"detail": "Internal server error"}, @@ -48,7 +53,7 @@ async def dispatch(self, request: Request, call_next): class RequestLoggingMiddleware(BaseHTTPMiddleware): """Log request method, path, and response time.""" - async def dispatch(self, request: Request, call_next): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: start = time.time() response = await call_next(request) duration = time.time() - start diff --git a/backend/app/di/container.py b/backend/app/di/container.py index b931ee3..420a734 100644 --- a/backend/app/di/container.py +++ b/backend/app/di/container.py @@ -1,4 +1,4 @@ -from typing import Generator +from collections.abc import Generator from sqlalchemy.orm import Session @@ -12,7 +12,7 @@ class DIContainer: """依存性注入コンテナ""" @staticmethod - def get_db() -> Generator[Session, None, None]: + def get_db() -> Generator[Session]: """データベースセッションを取得""" try: db = SessionLocal() diff --git a/backend/app/di/dependencies.py b/backend/app/di/dependencies.py index 93cae4f..0b02fbd 100644 --- a/backend/app/di/dependencies.py +++ b/backend/app/di/dependencies.py @@ -1,4 +1,4 @@ -from typing import Generator +from collections.abc import Generator from fastapi import Depends from sqlalchemy.orm import Session @@ -7,9 +7,10 @@ from app.repository.job_repository import JobRepository from app.repository.proposal_repository import ProposalRepository from app.repository.setting_repository import SettingRepository +from app.service.artifact_service import ArtifactService -def get_db() -> Generator[Session, None, None]: +def get_db() -> Generator[Session]: """データベースセッションを取得""" yield from DIContainer.get_db() @@ -24,3 +25,7 @@ def get_proposal_repository(db: Session = Depends(get_db)) -> ProposalRepository def get_setting_repository(db: Session = Depends(get_db)) -> SettingRepository: return DIContainer.get_setting_repository(db) + + +def get_artifact_service() -> ArtifactService: + return ArtifactService() diff --git a/backend/app/model/base.py b/backend/app/model/base.py index 701e647..5a507f7 100644 --- a/backend/app/model/base.py +++ b/backend/app/model/base.py @@ -1,9 +1,13 @@ from typing import Any + from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import as_declarative + @as_declarative() class Base: + __allow_unmapped__ = True + id: Any __name__: str diff --git a/backend/app/model/job.py b/backend/app/model/job.py index 97430f8..ff9fec6 100644 --- a/backend/app/model/job.py +++ b/backend/app/model/job.py @@ -1,6 +1,6 @@ import enum import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid from sqlalchemy.orm import relationship @@ -8,7 +8,7 @@ from app.model.base import Base -class JobStatus(str, enum.Enum): +class JobStatus(enum.StrEnum): PENDING = "pending" ANALYZING = "analyzing" ANALYZED = "analyzed" @@ -17,7 +17,7 @@ class JobStatus(str, enum.Enum): FAILED = "failed" -class ProposalStatus(str, enum.Enum): +class ProposalStatus(enum.StrEnum): PENDING = "pending" IMPLEMENTING = "implementing" COMPLETED = "completed" @@ -25,7 +25,7 @@ class ProposalStatus(str, enum.Enum): def _utcnow() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) class Job(Base): @@ -42,7 +42,7 @@ class Job(Base): created_at = Column(DateTime(timezone=True), default=_utcnow) updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) - proposals = relationship( + proposals: list["Proposal"] = relationship( "Proposal", back_populates="job", cascade="all, delete-orphan" ) @@ -51,25 +51,21 @@ class Proposal(Base): __tablename__ = "proposals" id = Column(Uuid, primary_key=True, default=uuid.uuid4) - job_id = Column( - Uuid, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False - ) + job_id = Column(Uuid, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False) proposal_index = Column(Integer, nullable=False) title = Column(String(200), nullable=False) concept = Column(Text, nullable=False) plan = Column(Text, nullable=False) # JSON array stored as text files = Column(Text, nullable=True) # JSON array stored as text complexity = Column(String(20), nullable=True) - status = Column( - Enum(ProposalStatus), nullable=False, default=ProposalStatus.PENDING - ) + status = Column(Enum(ProposalStatus), nullable=False, default=ProposalStatus.PENDING) after_screenshot_path = Column(String(500), nullable=True) diff_path = Column(Text, nullable=True) k8s_job_name = Column(String(200), nullable=True) error_message = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), default=_utcnow) - job = relationship("Job", back_populates="proposals") + job: "Job" = relationship("Job", back_populates="proposals") class Setting(Base): diff --git a/backend/app/repository/__init__.py b/backend/app/repository/__init__.py index 2051b29..6ec7928 100644 --- a/backend/app/repository/__init__.py +++ b/backend/app/repository/__init__.py @@ -13,14 +13,13 @@ # - *_repository_interface.py: リポジトリインターフェース # - *_repository_sql.py: SQL実装 -from .database import SessionLocal, engine +from .database import SessionLocal from .job_repository import JobRepository from .proposal_repository import ProposalRepository from .setting_repository import SettingRepository __all__ = [ "SessionLocal", - "engine", "JobRepository", "ProposalRepository", "SettingRepository", diff --git a/backend/app/repository/database.py b/backend/app/repository/database.py index c95d98a..543d269 100644 --- a/backend/app/repository/database.py +++ b/backend/app/repository/database.py @@ -1,19 +1,30 @@ -from app.core.config import settings -from sqlalchemy import create_engine -from sqlalchemy.orm import scoped_session, sessionmaker - -# データベース接続設定 -if settings.SQLALCHEMY_DATABASE_URI: - engine = create_engine( - settings.SQLALCHEMY_DATABASE_URI, - pool_pre_ping=True, - ) - SessionLocal = scoped_session( - sessionmaker(autocommit=False, autoflush=False, bind=engine) - ) +import logging +from functools import lru_cache + +from sqlalchemy import Engine, create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + + +@lru_cache +def get_engine() -> Engine: + settings = get_settings() + uri = settings.SQLALCHEMY_DATABASE_URI + if not uri: + raise ValueError("SQLALCHEMY_DATABASE_URI is not set") + + engine = create_engine(uri, pool_pre_ping=True) if settings.ENVIRONMENT == "development": - db_info = f"Using database at {settings.SQLALCHEMY_DATABASE_URI}" - print(db_info) -else: - raise ValueError("SQLALCHEMY_DATABASE_URI is not set") \ No newline at end of file + logger.info("Using database at %s", uri) + + return engine + + +def SessionLocal() -> Session: + engine = get_engine() + factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) + return factory() diff --git a/backend/app/repository/job_repository.py b/backend/app/repository/job_repository.py index e488ee1..6697573 100644 --- a/backend/app/repository/job_repository.py +++ b/backend/app/repository/job_repository.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID from sqlalchemy.orm import Session @@ -16,12 +15,10 @@ def create(self, job: Job) -> Job: self.db.refresh(job) return job - def get_by_id(self, job_id: UUID) -> Optional[Job]: + def get_by_id(self, job_id: UUID) -> Job | None: return self.db.query(Job).filter(Job.id == job_id).first() - def update_status( - self, job_id: UUID, status: JobStatus, **kwargs: object - ) -> Optional[Job]: + def update_status(self, job_id: UUID, status: JobStatus, **kwargs: object) -> Job | None: job = self.get_by_id(job_id) if job: job.status = status diff --git a/backend/app/repository/proposal_repository.py b/backend/app/repository/proposal_repository.py index b04ef7e..4af68ad 100644 --- a/backend/app/repository/proposal_repository.py +++ b/backend/app/repository/proposal_repository.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID from sqlalchemy.orm import Session @@ -16,12 +15,10 @@ def create(self, proposal: Proposal) -> Proposal: self.db.refresh(proposal) return proposal - def get_by_id(self, proposal_id: UUID) -> Optional[Proposal]: + def get_by_id(self, proposal_id: UUID) -> Proposal | None: return self.db.query(Proposal).filter(Proposal.id == proposal_id).first() - def get_by_job_and_index( - self, job_id: UUID, proposal_index: int - ) -> Optional[Proposal]: + def get_by_job_and_index(self, job_id: UUID, proposal_index: int) -> Proposal | None: return ( self.db.query(Proposal) .filter( @@ -41,7 +38,7 @@ def get_all_for_job(self, job_id: UUID) -> list[Proposal]: def update_status( self, proposal_id: UUID, status: ProposalStatus, **kwargs: object - ) -> Optional[Proposal]: + ) -> Proposal | None: proposal = self.get_by_id(proposal_id) if proposal: proposal.status = status diff --git a/backend/app/repository/setting_repository.py b/backend/app/repository/setting_repository.py index 19a2469..9389fbb 100644 --- a/backend/app/repository/setting_repository.py +++ b/backend/app/repository/setting_repository.py @@ -1,5 +1,3 @@ -from typing import Optional - from sqlalchemy.orm import Session from app.model.job import Setting @@ -9,7 +7,7 @@ class SettingRepository: def __init__(self, db: Session) -> None: self.db = db - def get_by_key(self, key: str) -> Optional[Setting]: + def get_by_key(self, key: str) -> Setting | None: return self.db.query(Setting).filter(Setting.key == key).first() def upsert(self, key: str, value: str) -> Setting: diff --git a/backend/app/router/api.py b/backend/app/router/api.py index 0d116b4..acf7d77 100755 --- a/backend/app/router/api.py +++ b/backend/app/router/api.py @@ -1,8 +1,10 @@ +import logging + from fastapi import APIRouter, Depends from sqlalchemy import text from sqlalchemy.orm import Session + from app.di import get_db -import logging # ロガーの設定 logger = logging.getLogger(__name__) @@ -12,13 +14,13 @@ @router.get("/") -def get_root(): +def get_root() -> dict[str, str]: """ルートエンドポイント""" return {"message": "UI Recommender API is running"} @router.get("/health") -def health_check(db: Session = Depends(get_db)): +def health_check(db: Session = Depends(get_db)) -> dict[str, str]: """ ヘルスチェック用エンドポイント - アプリケーションの稼働状態を確認 @@ -29,14 +31,7 @@ def health_check(db: Session = Depends(get_db)): # データベース接続確認 result = db.execute(text("SELECT 1")) result.scalar() # 結果を取得して接続を確認 - return { - "status": "healthy", - "database": "connected" - } + return {"status": "healthy", "database": "connected"} except Exception as e: logger.error(f"Health check failed: {e}") - return { - "status": "unhealthy", - "database": "disconnected", - "error": str(e) - } \ No newline at end of file + return {"status": "unhealthy", "database": "disconnected", "error": str(e)} diff --git a/backend/app/router/jobs.py b/backend/app/router/jobs.py index 50ac760..2449528 100644 --- a/backend/app/router/jobs.py +++ b/backend/app/router/jobs.py @@ -5,9 +5,9 @@ from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from app.di.dependencies import get_db +from app.di.dependencies import get_artifact_service, get_db +from app.model.job import Job, Proposal from app.repository.job_repository import JobRepository -from app.repository.proposal_repository import ProposalRepository from app.schema.job_schema import ( CreateJobRequest, ImplementRequest, @@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/jobs", tags=["jobs"]) -def _to_proposal_response(proposal, job_id: UUID) -> ProposalResponse: +def _to_proposal_response(proposal: Proposal, job_id: UUID) -> ProposalResponse: """Convert Proposal model to ProposalResponse.""" try: plan = json.loads(proposal.plan) if proposal.plan else [] @@ -39,7 +39,7 @@ def _to_proposal_response(proposal, job_id: UUID) -> ProposalResponse: plan=plan, files=files, complexity=proposal.complexity, - status=proposal.status.value, + status=proposal.status.value if proposal.status else "pending", after_screenshot_url=( f"/api/jobs/{job_id}/proposals/{proposal.proposal_index}/screenshot" if proposal.after_screenshot_path @@ -50,19 +50,18 @@ def _to_proposal_response(proposal, job_id: UUID) -> ProposalResponse: ) -def _to_job_response(job) -> JobResponse: +def _to_job_response(job: Job) -> JobResponse: """Convert Job model to JobResponse.""" - proposals = [_to_proposal_response(p, job.id) for p in job.proposals] + job_id = job.id + proposals = [_to_proposal_response(p, job_id) for p in job.proposals] # type: ignore[arg-type] return JobResponse( - id=job.id, - status=job.status.value, + id=job_id, + status=job.status.value if job.status else "pending", repo_url=job.repo_url, branch=job.branch, instruction=job.instruction, before_screenshot_url=( - f"/api/jobs/{job.id}/screenshot/before" - if job.before_screenshot_path - else None + f"/api/jobs/{job.id}/screenshot/before" if job.before_screenshot_path else None ), error_message=job.error_message, proposals=proposals, @@ -72,9 +71,7 @@ def _to_job_response(job) -> JobResponse: @router.post("/", response_model=JobResponse, status_code=201) -async def create_job( - request: CreateJobRequest, db: Session = Depends(get_db) -) -> JobResponse: +async def create_job(request: CreateJobRequest, db: Session = Depends(get_db)) -> JobResponse: usecase = CreateJobUseCase(db) job = await usecase.execute( repo_url=request.repo_url, @@ -108,13 +105,14 @@ async def implement_proposals( try: job = await usecase.execute(job_id, request.proposal_indices) except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e return _to_job_response(job) @router.get("/{job_id}/screenshot/before") -async def get_before_screenshot(job_id: UUID) -> FileResponse: - artifacts = ArtifactService() +async def get_before_screenshot( + job_id: UUID, artifacts: ArtifactService = Depends(get_artifact_service) +) -> FileResponse: path = artifacts.get_before_screenshot_path(str(job_id)) if not path: raise HTTPException(status_code=404, detail="Screenshot not found") @@ -123,9 +121,10 @@ async def get_before_screenshot(job_id: UUID) -> FileResponse: @router.get("/{job_id}/proposals/{proposal_index}/screenshot") async def get_after_screenshot( - job_id: UUID, proposal_index: int + job_id: UUID, + proposal_index: int, + artifacts: ArtifactService = Depends(get_artifact_service), ) -> FileResponse: - artifacts = ArtifactService() path = artifacts.get_after_screenshot_path(str(job_id), proposal_index) if not path: raise HTTPException(status_code=404, detail="Screenshot not found") @@ -133,8 +132,11 @@ async def get_after_screenshot( @router.get("/{job_id}/proposals/{proposal_index}/diff") -async def get_diff(job_id: UUID, proposal_index: int) -> dict: - artifacts = ArtifactService() +async def get_diff( + job_id: UUID, + proposal_index: int, + artifacts: ArtifactService = Depends(get_artifact_service), +) -> dict: diff = artifacts.get_diff(str(job_id), proposal_index) if not diff: raise HTTPException(status_code=404, detail="Diff not found") diff --git a/backend/app/router/settings.py b/backend/app/router/settings.py index 153895f..0ddadb6 100644 --- a/backend/app/router/settings.py +++ b/backend/app/router/settings.py @@ -12,17 +12,12 @@ def get_settings(db: Session = Depends(get_db)) -> list[SettingResponse]: repo = SettingRepository(db) return [ - SettingResponse(key=s.key, value=s.value, updated_at=s.updated_at) - for s in repo.list_all() + SettingResponse(key=s.key, value=s.value, updated_at=s.updated_at) for s in repo.list_all() ] @router.post("/", response_model=SettingResponse) -def save_setting( - request: SettingRequest, db: Session = Depends(get_db) -) -> SettingResponse: +def save_setting(request: SettingRequest, db: Session = Depends(get_db)) -> SettingResponse: repo = SettingRepository(db) setting = repo.upsert(request.key, request.value) - return SettingResponse( - key=setting.key, value=setting.value, updated_at=setting.updated_at - ) + return SettingResponse(key=setting.key, value=setting.value, updated_at=setting.updated_at) diff --git a/backend/app/schema/job_schema.py b/backend/app/schema/job_schema.py index b166291..fc8e489 100644 --- a/backend/app/schema/job_schema.py +++ b/backend/app/schema/job_schema.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -20,10 +19,10 @@ class ProposalResponse(BaseModel): concept: str plan: list[str] files: list[dict] - complexity: Optional[str] + complexity: str | None status: str - after_screenshot_url: Optional[str] = None - error_message: Optional[str] = None + after_screenshot_url: str | None = None + error_message: str | None = None created_at: datetime @@ -35,17 +34,15 @@ class JobResponse(BaseModel): repo_url: str branch: str instruction: str - before_screenshot_url: Optional[str] = None - error_message: Optional[str] = None + before_screenshot_url: str | None = None + error_message: str | None = None proposals: list[ProposalResponse] = [] created_at: datetime updated_at: datetime class ImplementRequest(BaseModel): - proposal_indices: list[int] = Field( - ..., description="Indices of proposals to implement" - ) + proposal_indices: list[int] = Field(..., description="Indices of proposals to implement") class SettingRequest(BaseModel): diff --git a/backend/app/service/artifact_service.py b/backend/app/service/artifact_service.py index 6f9fd46..4378adb 100644 --- a/backend/app/service/artifact_service.py +++ b/backend/app/service/artifact_service.py @@ -3,9 +3,9 @@ import logging import re from pathlib import Path -from typing import Optional +from typing import Any -from app.core.config import settings +from app.core.config import get_settings logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ class ArtifactService: """ def __init__(self) -> None: - self.base_dir = Path(settings.ARTIFACTS_DIR) + self.base_dir = Path(get_settings().ARTIFACTS_DIR) def extract_artifacts_from_logs(self, job_id: str, logs: str) -> dict[str, str]: """Extract artifacts embedded in pod logs and save them locally. @@ -79,7 +79,9 @@ def extract_impl_artifacts_from_logs( saved[name] = str(path) logger.info( "Extracted binary artifact %s for job %s proposal %d", - name, job_id, proposal_index, + name, + job_id, + proposal_index, ) except Exception as e: logger.error("Failed to decode %s: %s", name, e) @@ -89,16 +91,18 @@ def extract_impl_artifacts_from_logs( saved[name] = str(path) logger.info( "Extracted text artifact %s for job %s proposal %d", - name, job_id, proposal_index, + name, + job_id, + proposal_index, ) return saved - def get_before_screenshot_path(self, job_id: str) -> Optional[Path]: + def get_before_screenshot_path(self, job_id: str) -> Path | None: path = self.base_dir / job_id / "before.png" return path if path.exists() else None - def get_proposals_json(self, job_id: str) -> Optional[list[dict]]: + def get_proposals_json(self, job_id: str) -> list[dict[str, Any]] | None: """Read proposals.json from local cache.""" path = self.base_dir / job_id / "proposals.json" if not path.exists(): @@ -108,7 +112,8 @@ def get_proposals_json(self, job_id: str) -> Optional[list[dict]]: try: data = json.loads(path.read_text()) if isinstance(data, dict) and "proposals" in data: - return data["proposals"] + proposals: list[dict[str, Any]] = data["proposals"] + return proposals if isinstance(data, list): return data logger.warning("Unexpected proposals format for job %s", job_id) @@ -117,22 +122,12 @@ def get_proposals_json(self, job_id: str) -> Optional[list[dict]]: logger.error("Failed to parse proposals for job %s: %s", job_id, e) return None - def get_after_screenshot_path( - self, job_id: str, proposal_index: int - ) -> Optional[Path]: - path = ( - self.base_dir / job_id / "proposals" / str(proposal_index) / "after.png" - ) + def get_after_screenshot_path(self, job_id: str, proposal_index: int) -> Path | None: + path = self.base_dir / job_id / "proposals" / str(proposal_index) / "after.png" return path if path.exists() else None - def get_diff(self, job_id: str, proposal_index: int) -> Optional[str]: - path = ( - self.base_dir - / job_id - / "proposals" - / str(proposal_index) - / "changes.diff" - ) + def get_diff(self, job_id: str, proposal_index: int) -> str | None: + path = self.base_dir / job_id / "proposals" / str(proposal_index) / "changes.diff" if not path.exists(): return None try: @@ -141,9 +136,7 @@ def get_diff(self, job_id: str, proposal_index: int) -> Optional[str]: logger.error("Failed to read diff: %s", e) return None - def write_proposal_plan( - self, job_id: str, proposal_index: int, plan_text: str - ) -> Path: + def write_proposal_plan(self, job_id: str, proposal_index: int, plan_text: str) -> Path: """Write proposal plan to a file (used for local reference).""" path = self.base_dir / job_id / f"proposal_{proposal_index}_plan.txt" path.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/service/k8s_service.py b/backend/app/service/k8s_service.py index 2439393..b03fdc6 100644 --- a/backend/app/service/k8s_service.py +++ b/backend/app/service/k8s_service.py @@ -1,11 +1,10 @@ import asyncio import logging -from typing import Optional from kubernetes import client, config from kubernetes.client.rest import ApiException -from app.core.config import settings +from app.core.config import get_settings logger = logging.getLogger(__name__) @@ -14,6 +13,7 @@ class K8sService: """Kubernetes Job management service.""" def __init__(self) -> None: + settings = get_settings() if settings.K8S_IN_CLUSTER: config.load_incluster_config() else: @@ -23,14 +23,14 @@ def __init__(self) -> None: # K8s 証明書は 127.0.0.1 向けなので SSL 検証もスキップする。 k8s_config = client.Configuration.get_default_copy() if "127.0.0.1" in k8s_config.host: - k8s_config.host = k8s_config.host.replace( - "127.0.0.1", "host.docker.internal" - ) + k8s_config.host = k8s_config.host.replace("127.0.0.1", "host.docker.internal") k8s_config.verify_ssl = False client.Configuration.set_default(k8s_config) self.batch_v1 = client.BatchV1Api() self.core_v1 = client.CoreV1Api() self.namespace = settings.K8S_NAMESPACE + self.worker_image = settings.WORKER_IMAGE + self.worker_deadline_seconds = settings.WORKER_DEADLINE_SECONDS def _build_worker_container( self, mode: str, env_vars: list[client.V1EnvVar] @@ -51,7 +51,7 @@ def _build_worker_container( ] return client.V1Container( name="worker", - image=settings.WORKER_IMAGE, + image=self.worker_image, image_pull_policy="Never", env=base_env + env_vars, resources=client.V1ResourceRequirements( @@ -98,7 +98,7 @@ def _build_job_spec( template=template, backoff_limit=1, ttl_seconds_after_finished=1800, - active_deadline_seconds=settings.WORKER_DEADLINE_SECONDS, + active_deadline_seconds=self.worker_deadline_seconds, ), ) @@ -162,9 +162,7 @@ def create_implementation_job( logger.info("Created implementation K8s Job: %s", job_name) return job_name - async def wait_for_job( - self, job_name: str, timeout: int = 900, poll_interval: int = 5 - ) -> str: + async def wait_for_job(self, job_name: str, timeout: int = 900, poll_interval: int = 5) -> str: """Poll K8s Job status until completion. Returns 'succeeded', 'failed', or 'timeout'.""" elapsed = 0 while elapsed < timeout: @@ -187,7 +185,7 @@ async def wait_for_job( logger.warning("K8s Job %s timed out after %ds", job_name, timeout) return "timeout" - def get_job_logs(self, job_name: str) -> Optional[str]: + def get_job_logs(self, job_name: str) -> str | None: """Get logs from the job's pod.""" try: pods = self.core_v1.list_namespaced_pod( @@ -195,9 +193,11 @@ def get_job_logs(self, job_name: str) -> Optional[str]: label_selector=f"job-name={job_name}", ) if pods.items: - return self.core_v1.read_namespaced_pod_log( - name=pods.items[0].metadata.name, - namespace=self.namespace, + return str( + self.core_v1.read_namespaced_pod_log( + name=pods.items[0].metadata.name, + namespace=self.namespace, + ) ) except ApiException as e: logger.error("Error getting logs for K8s Job %s: %s", job_name, e) diff --git a/backend/app/usecase/job_usecase.py b/backend/app/usecase/job_usecase.py index efc9153..3bf21ac 100644 --- a/backend/app/usecase/job_usecase.py +++ b/backend/app/usecase/job_usecase.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session -from app.core.config import settings +from app.core.config import get_settings from app.model.job import Job, JobStatus, Proposal, ProposalStatus from app.repository.database import SessionLocal from app.repository.job_repository import JobRepository @@ -35,9 +35,7 @@ async def execute(self, repo_url: str, branch: str, instruction: str) -> Job: job_id = str(job.id) # Run analysis in background (separate DB session) - asyncio.create_task( - self._run_analysis(job_id, repo_url, branch, instruction) - ) + asyncio.create_task(self._run_analysis(job_id, repo_url, branch, instruction)) return job @@ -53,24 +51,26 @@ async def _run_analysis( job_repo.update_status(UUID(job_id), JobStatus.ANALYZING) graph = build_analyzer_graph() - result = await graph.ainvoke({ - "job_id": job_id, - "repo_url": repo_url, - "branch": branch, - "instruction": instruction, - "num_proposals": settings.MAX_PROPOSALS, - "k8s_job_name": None, - "status": "pending", - "error": None, - "proposals": None, - "before_screenshot_path": None, - }) + result = await graph.ainvoke( + { + "job_id": job_id, + "repo_url": repo_url, + "branch": branch, + "instruction": instruction, + "num_proposals": get_settings().MAX_PROPOSALS, + "k8s_job_name": None, + "status": "pending", + "error": None, + "proposals": None, + "before_screenshot_path": None, + } + ) if result.get("proposals"): proposals = [] for i, prop in enumerate(result["proposals"]): proposal = Proposal( - job_id=UUID(job_id), + job_id=UUID(job_id), # type: ignore[arg-type] proposal_index=i, title=prop.get("title", f"Proposal {i + 1}"), concept=prop.get("concept", ""), @@ -101,9 +101,9 @@ async def _run_analysis( str(job_id), repo_url, branch, - proposal.proposal_index, + proposal.proposal_index or 0, str(proposal.id), - proposal.plan, + str(proposal.plan), ) ) logger.info( @@ -113,9 +113,7 @@ async def _run_analysis( ) else: error = result.get("error", "No proposals generated") - job_repo.update_status( - UUID(job_id), JobStatus.FAILED, error_message=error - ) + job_repo.update_status(UUID(job_id), JobStatus.FAILED, error_message=error) logger.warning("Analysis failed for job %s: %s", job_id, error) except Exception: @@ -155,16 +153,19 @@ async def execute(self, job_id: UUID, proposal_indices: list[int]) -> Job: asyncio.create_task( self._run_implementation( str(job_id), - job.repo_url, - job.branch, + str(job.repo_url), + str(job.branch), idx, str(proposal.id), - proposal.plan, + str(proposal.plan), ) ) # Return the updated job - return self.job_repo.get_by_id(job_id) + updated_job = self.job_repo.get_by_id(job_id) + if not updated_job: + raise ValueError("Job not found after update") + return updated_job async def _run_implementation( self, @@ -191,34 +192,31 @@ async def _run_implementation_static( """Background task: run one implementation LangGraph workflow.""" db = SessionLocal() try: - job_repo = JobRepository(db) proposal_repo = ProposalRepository(db) - proposal_repo.update_status( - UUID(proposal_id), ProposalStatus.IMPLEMENTING - ) + proposal_repo.update_status(UUID(proposal_id), ProposalStatus.IMPLEMENTING) # Write plan to artifact dir for the worker to read artifacts = ArtifactService() artifacts.write_proposal_plan(job_id, proposal_index, plan_json) graph = build_implementation_graph() - result = await graph.ainvoke({ - "job_id": job_id, - "repo_url": repo_url, - "branch": branch, - "proposal_index": proposal_index, - "proposal_plan": plan_json, - "k8s_job_name": None, - "status": "pending", - "error": None, - "after_screenshot_path": None, - "diff_content": None, - }) - - if result.get("status") == "succeeded" or result.get( - "after_screenshot_path" - ): + result = await graph.ainvoke( + { + "job_id": job_id, + "repo_url": repo_url, + "branch": branch, + "proposal_index": proposal_index, + "proposal_plan": plan_json, + "k8s_job_name": None, + "status": "pending", + "error": None, + "after_screenshot_path": None, + "diff_content": None, + } + ) + + if result.get("status") == "succeeded" or result.get("after_screenshot_path"): proposal_repo.update_status( UUID(proposal_id), ProposalStatus.COMPLETED, @@ -276,13 +274,10 @@ def _check_job_completion(db: Session, job_id: UUID) -> None: return all_done = all( - p.status in (ProposalStatus.COMPLETED, ProposalStatus.FAILED) - for p in proposals + p.status in (ProposalStatus.COMPLETED, ProposalStatus.FAILED) for p in proposals ) if all_done: - any_succeeded = any( - p.status == ProposalStatus.COMPLETED for p in proposals - ) + any_succeeded = any(p.status == ProposalStatus.COMPLETED for p in proposals) new_status = JobStatus.COMPLETED if any_succeeded else JobStatus.FAILED job_repo.update_status(job_id, new_status) logger.info("Job %s completed with status: %s", job_id, new_status) diff --git a/backend/app/workflow/analyzer_graph.py b/backend/app/workflow/analyzer_graph.py index d4d3c42..9fb68ae 100644 --- a/backend/app/workflow/analyzer_graph.py +++ b/backend/app/workflow/analyzer_graph.py @@ -1,4 +1,5 @@ import logging +from typing import Any from langgraph.graph import END, START, StateGraph @@ -25,12 +26,14 @@ async def create_k8s_job(state: AnalyzerState) -> dict: async def wait_for_job(state: AnalyzerState) -> dict: """Poll K8s Job until completion.""" k8s = K8sService() - result = await k8s.wait_for_job(state["k8s_job_name"]) + k8s_job_name = state["k8s_job_name"] + assert k8s_job_name is not None + result = await k8s.wait_for_job(k8s_job_name) if result == "succeeded": return {"status": "succeeded"} - logs = k8s.get_job_logs(state["k8s_job_name"]) + logs = k8s.get_job_logs(k8s_job_name) error_msg = f"Analyzer job {result}." if logs: error_msg += f" Logs: {logs[:500]}" @@ -43,7 +46,9 @@ async def extract_results(state: AnalyzerState) -> dict: artifacts = ArtifactService() # Get pod logs and extract embedded artifacts - logs = k8s.get_job_logs(state["k8s_job_name"]) + k8s_job_name = state["k8s_job_name"] + assert k8s_job_name is not None + logs = k8s.get_job_logs(k8s_job_name) if logs: artifacts.extract_artifacts_from_logs(state["job_id"], logs) @@ -65,7 +70,7 @@ def route_after_wait(state: AnalyzerState) -> str: return END -def build_analyzer_graph() -> StateGraph: +def build_analyzer_graph() -> Any: """Build and compile the analyzer LangGraph.""" graph = StateGraph(AnalyzerState) diff --git a/backend/app/workflow/implementation_graph.py b/backend/app/workflow/implementation_graph.py index fb8c928..008b4d2 100644 --- a/backend/app/workflow/implementation_graph.py +++ b/backend/app/workflow/implementation_graph.py @@ -1,4 +1,5 @@ import logging +from typing import Any from langgraph.graph import END, START, StateGraph @@ -15,9 +16,7 @@ async def create_k8s_job(state: ImplementationState) -> dict: # Write proposal plan to artifact directory to avoid K8s env var size limits artifacts = ArtifactService() - artifacts.write_proposal_plan( - state["job_id"], state["proposal_index"], state["proposal_plan"] - ) + artifacts.write_proposal_plan(state["job_id"], state["proposal_index"], state["proposal_plan"]) job_name = k8s.create_implementation_job( job_id=state["job_id"], @@ -32,12 +31,14 @@ async def create_k8s_job(state: ImplementationState) -> dict: async def wait_for_job(state: ImplementationState) -> dict: """Poll K8s Job until completion.""" k8s = K8sService() - result = await k8s.wait_for_job(state["k8s_job_name"]) + k8s_job_name = state["k8s_job_name"] + assert k8s_job_name is not None + result = await k8s.wait_for_job(k8s_job_name) if result == "succeeded": return {"status": "succeeded"} - logs = k8s.get_job_logs(state["k8s_job_name"]) + logs = k8s.get_job_logs(k8s_job_name) error_msg = f"Implementation job {result}." if logs: error_msg += f" Logs: {logs[:500]}" @@ -50,15 +51,13 @@ async def extract_results(state: ImplementationState) -> dict: artifacts = ArtifactService() # Get pod logs and extract embedded artifacts - logs = k8s.get_job_logs(state["k8s_job_name"]) + k8s_job_name = state["k8s_job_name"] + assert k8s_job_name is not None + logs = k8s.get_job_logs(k8s_job_name) if logs: - artifacts.extract_impl_artifacts_from_logs( - state["job_id"], state["proposal_index"], logs - ) + artifacts.extract_impl_artifacts_from_logs(state["job_id"], state["proposal_index"], logs) - after_path = artifacts.get_after_screenshot_path( - state["job_id"], state["proposal_index"] - ) + after_path = artifacts.get_after_screenshot_path(state["job_id"], state["proposal_index"]) diff_content = artifacts.get_diff(state["job_id"], state["proposal_index"]) return { @@ -74,7 +73,7 @@ def route_after_wait(state: ImplementationState) -> str: return END -def build_implementation_graph() -> StateGraph: +def build_implementation_graph() -> Any: """Build and compile the implementation LangGraph.""" graph = StateGraph(ImplementationState) diff --git a/backend/app/workflow/state.py b/backend/app/workflow/state.py index 71a212f..5b8a0b1 100644 --- a/backend/app/workflow/state.py +++ b/backend/app/workflow/state.py @@ -1,4 +1,4 @@ -from typing import Optional, TypedDict +from typing import TypedDict class AnalyzerState(TypedDict): @@ -12,13 +12,13 @@ class AnalyzerState(TypedDict): num_proposals: int # Intermediate - k8s_job_name: Optional[str] + k8s_job_name: str | None status: str # pending, running, succeeded, failed, timeout - error: Optional[str] + error: str | None # Output - proposals: Optional[list[dict]] - before_screenshot_path: Optional[str] + proposals: list[dict] | None + before_screenshot_path: str | None class ImplementationState(TypedDict): @@ -32,10 +32,10 @@ class ImplementationState(TypedDict): proposal_plan: str # Intermediate - k8s_job_name: Optional[str] + k8s_job_name: str | None status: str - error: Optional[str] + error: str | None # Output - after_screenshot_path: Optional[str] - diff_content: Optional[str] + after_screenshot_path: str | None + diff_content: str | None diff --git a/backend/migration/env.py b/backend/migration/env.py index b4b1bcc..3c6a314 100755 --- a/backend/migration/env.py +++ b/backend/migration/env.py @@ -1,9 +1,11 @@ from logging.config import fileConfig -from app.core.config import settings -from sqlalchemy import engine_from_config -from sqlalchemy import pool -from app.model.base import Base + from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import get_settings +from app.model.base import Base +from app.model.job import Job, Proposal, Setting # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -14,17 +16,14 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) -# add your model's MetaData object here -# for 'autogenerate' support -from app.model.job import Job, Proposal, Setting # noqa: F401 - target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. -config.set_section_option("alembic", "DB_URL", settings.SQLALCHEMY_DATABASE_URI) +config.set_section_option("alembic", "DB_URL", get_settings().SQLALCHEMY_DATABASE_URI) + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -64,9 +63,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index cb519e2..b23dd6e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,10 +21,54 @@ test = [ "pytest-asyncio>=0.24.0", "httpx>=0.27.0", ] +dev = [ + "ruff>=0.11.0", + "mypy>=1.14.0", +] [tool.pytest.ini_options] asyncio_mode = "auto" +[tool.ruff] +target-version = "py313" +line-length = 100 +exclude = ["migration/versions/"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = ["B008"] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.mypy] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +show_error_codes = true +plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"] +exclude = ["migration/"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false +disable_error_code = ["arg-type"] + +[[tool.mypy.overrides]] +module = "app.workflow.*" +disallow_untyped_calls = false + +[[tool.mypy.overrides]] +module = ["langgraph.*", "kubernetes.*", "pytest.*", "pytest_asyncio.*", "_pytest.*"] +ignore_missing_imports = true + [tool.hatch.build.targets.wheel] packages = ["app"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3fcf159..2445715 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,3 +1,5 @@ +from collections.abc import Generator + import pytest from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker @@ -31,7 +33,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record): @pytest.fixture() -def db(engine) -> Session: +def db(engine) -> Generator[Session]: """Provide a transactional DB session that rolls back after each test.""" connection = engine.connect() transaction = connection.begin() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3843581..a6f5e71 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,21 +1,29 @@ import uuid +from pathlib import Path import pytest from fastapi.testclient import TestClient -from app.di.dependencies import get_db +from app.di.dependencies import get_artifact_service, get_db from app.main import app from app.model.job import Job, JobStatus, Proposal +from app.service.artifact_service import ArtifactService @pytest.fixture() -def client(db): - """Create a test client with overridden DB dependency.""" +def client(db, tmp_path): + """Create a test client with overridden dependencies.""" def override_get_db(): yield db + def override_get_artifact_service() -> ArtifactService: + service = ArtifactService.__new__(ArtifactService) + service.base_dir = Path(tmp_path) + return service + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_artifact_service] = override_get_artifact_service with TestClient(app) as c: yield c app.dependency_overrides.clear() @@ -113,9 +121,7 @@ def test_create_success(self, client, monkeypatch): # Mock the asyncio.create_task to avoid launching background analysis import app.usecase.job_usecase as usecase_mod - monkeypatch.setattr( - usecase_mod.asyncio, "create_task", lambda coro: coro.close() - ) + monkeypatch.setattr(usecase_mod.asyncio, "create_task", lambda coro: coro.close()) response = client.post( "/api/jobs/", @@ -166,9 +172,7 @@ def test_list_settings_empty(self, client): assert isinstance(response.json(), list) def test_create_and_list_setting(self, client): - response = client.post( - "/api/settings/", json={"key": "max_proposals", "value": "5"} - ) + response = client.post("/api/settings/", json={"key": "max_proposals", "value": "5"}) assert response.status_code == 200 data = response.json() assert data["key"] == "max_proposals" @@ -181,8 +185,6 @@ def test_create_and_list_setting(self, client): def test_update_setting(self, client): client.post("/api/settings/", json={"key": "theme", "value": "dark"}) - response = client.post( - "/api/settings/", json={"key": "theme", "value": "light"} - ) + response = client.post("/api/settings/", json={"key": "theme", "value": "light"}) assert response.status_code == 200 assert response.json()["value"] == "light" diff --git a/backend/tests/test_repository.py b/backend/tests/test_repository.py index ac0c8e1..4b72a0f 100644 --- a/backend/tests/test_repository.py +++ b/backend/tests/test_repository.py @@ -1,6 +1,6 @@ import uuid -from app.model.job import Job, JobStatus, Proposal, ProposalStatus, Setting +from app.model.job import Job, JobStatus, Proposal, ProposalStatus from app.repository.job_repository import JobRepository from app.repository.proposal_repository import ProposalRepository from app.repository.setting_repository import SettingRepository @@ -38,9 +38,7 @@ def test_update_status(self, db): ) ) - updated = repo.update_status( - job.id, JobStatus.ANALYZING, k8s_job_name="test-job" - ) + updated = repo.update_status(job.id, JobStatus.ANALYZING, k8s_job_name="test-job") assert updated is not None assert updated.status == JobStatus.ANALYZING assert updated.k8s_job_name == "test-job" @@ -55,9 +53,7 @@ def test_update_status_with_error(self, db): ) ) - updated = repo.update_status( - job.id, JobStatus.FAILED, error_message="Something went wrong" - ) + updated = repo.update_status(job.id, JobStatus.FAILED, error_message="Something went wrong") assert updated is not None assert updated.status == JobStatus.FAILED assert updated.error_message == "Something went wrong" diff --git a/backend/uv.lock b/backend/uv.lock index 21ff345..88dc43a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -62,6 +62,10 @@ dependencies = [ ] [package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "ruff" }, +] test = [ { name = "httpx" }, { name = "pytest" }, @@ -75,14 +79,16 @@ requires-dist = [ { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, { name = "kubernetes", specifier = ">=32.0.0" }, { name = "langgraph", specifier = ">=0.4.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, { name = "psycopg2-binary", specifier = ">=2.9.11,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.13.0,<3.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, { name = "sqlalchemy", specifier = ">=2.0.46,<3.0.0" }, { name = "uvicorn", specifier = ">=0.40.0,<1.0.0" }, ] -provides-extras = ["test"] +provides-extras = ["test", "dev"] [[package]] name = "certifi" @@ -402,6 +408,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/9d/5a68b6b5e313ffabbb9725d18a71edb48177fd6d3ad329c07801d2a8e862/langsmith-0.7.3-py3-none-any.whl", hash = "sha256:03659bf9274e6efcead361c9c31a7849ea565ae0d6c0d73e1d8b239029eff3be", size = 325718, upload-time = "2026-02-13T23:25:31.52Z" }, ] +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -466,6 +519,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -552,6 +641,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -807,6 +905,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..91a3983 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +package-lock.json diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..86c151f --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 100 +} diff --git a/frontend/README.md b/frontend/README.md index 6813d90..ef6ce59 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,7 @@ # UI Recommender Frontend ## セットアップ + ```bash # 依存パッケージのインストール npm install @@ -8,9 +9,11 @@ npm install # 開発サーバー起動 npm run dev ``` + 開発サーバーが http://localhost:5173 で起動します。 ## 利用可能なコマンド + ```bash # 開発サーバー起動 npm run dev @@ -26,6 +29,7 @@ npm run lint ``` ## 技術スタック + - React 19 - TypeScript -- Vite \ No newline at end of file +- Vite diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c68124..7e685f3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "prettier": "^3.8.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1" @@ -2831,6 +2832,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4e18d32..29dc9d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "type-check": "tsc -b", + "format:check": "prettier --check .", + "format": "prettier --write .", "preview": "vite preview" }, "dependencies": { @@ -24,6 +27,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "prettier": "^3.8.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d7ded3..bdd9af2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,16 +18,12 @@ function App() {
Edit src/App.tsx and save to test HMR
- Click on the Vite and React logos to learn more -
+Click on the Vite and React logos to learn more
> ) } diff --git a/frontend/src/components/DiffViewer.tsx b/frontend/src/components/DiffViewer.tsx index b080e82..abb1d47 100644 --- a/frontend/src/components/DiffViewer.tsx +++ b/frontend/src/components/DiffViewer.tsx @@ -1,7 +1,7 @@ type DiffViewerProps = { - diff: string; - onClose: () => void; -}; + diff: string + onClose: () => void +} export default function DiffViewer({ diff, onClose }: DiffViewerProps) { return ( @@ -32,7 +32,14 @@ export default function DiffViewer({ diff, onClose }: DiffViewerProps) { }} onClick={(e) => e.stopPropagation()} > -This is an example page using React Router v7.