From 8d0c994e0377472ef2b0677c614345abb5b85334 Mon Sep 17 00:00:00 2001 From: Kohtaroh Sakaue Date: Sat, 1 Aug 2026 10:14:47 +0900 Subject: [PATCH] =?UTF-8?q?chore(#148):=20FastAPI=E3=81=AE=E5=9E=8B/?= =?UTF-8?q?=E3=82=B9=E3=82=BF=E3=82=A4=E3=83=AB=E8=A6=8F=E7=B4=84=E3=82=92?= =?UTF-8?q?ruff=E3=83=BBmypy=E3=83=BBCI=E3=81=A7=E6=A9=9F=E6=A2=B0?= =?UTF-8?q?=E7=9A=84=E3=81=AB=E5=BC=B7=E5=88=B6=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruff: FAST002 を有効化し、Annotated形式を今後も強制する - mypy: firebase_admin(型スタブ未配布)をignore_missing_imports、 types-nanoidを追加、list[Model]→list[Schema]の不変性エラーを Sequence化で解消、notification.pyの変数名衝突を解消 - ruff format による既存ファイルの整形差分を解消 - server/scripts/verify.sh を新規追加し、ruff check/format・mypy・ FastAPIのimport+OpenAPI生成スモークテストを一括実行できるようにする (DB起動不要でリファクタの回帰を検知できる) - CI (ci_server.yml) に verify.sh 実行ステップを追加。従来 uv sync の みでlint/型チェックが一切実行されていなかったギャップを埋める - .claude/rules/backend.md にAnnotated化・DI引数の並び順・検証方法を明文化 Co-Authored-By: Claude Sonnet 5 --- .claude/rules/backend.md | 19 ++++++++++ .github/workflows/ci_server.yml | 4 ++ server/app/cruds/blocks.py | 9 +---- server/app/cruds/notification.py | 18 ++++----- server/app/cruds/pages.py | 4 +- server/app/cruds/trips.py | 4 +- server/app/notification_content.py | 4 +- server/app/oidc.py | 5 ++- server/app/routers/blocks.py | 3 +- server/app/routers/pages.py | 3 +- server/app/routers/trips.py | 3 +- server/app/schemas/location.py | 8 +++- server/app/schemas/trip.py | 4 +- server/pyproject.toml | 13 +++++++ server/scripts/verify.sh | 59 ++++++++++++++++++++++++++++++ server/uv.lock | 11 ++++++ 16 files changed, 141 insertions(+), 30 deletions(-) create mode 100755 server/scripts/verify.sh diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 74b7a864..2c6474ac 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -51,6 +51,25 @@ server/ - HTTPメソッド適切な使用(GET, POST, PUT, DELETE, PATCH) - レスポンス形式は一貫性を保つ +#### 依存/パラメータの書き方(Annotated 必須) + +- FastAPI の `Depends` / `Query` / `Path` / `Header` / `Body` は **必ず `Annotated` 形式**で書く。 + デフォルト値へ直接代入する旧形式は禁止(`ruff` の `FAST002` で CI が自動検出する)。 + + ```python + # NG(旧形式) + async def h(db: AsyncSession = Depends(get_db_session)): ... + + # OK(Annotated) + async def h(db: Annotated[AsyncSession, Depends(get_db_session)]): ... + ``` + +- **DI(`Depends`)系引数は関数シグネチャの先頭に置く**。Annotated 形式はデフォルト値を + 持たないため引数順の制約がなく、依存を先頭・パスパラメータ/ボディを後ろに並べられる。 +- デフォルト値がある場合は Annotated の外に出す: `q: Annotated[int, Query(ge=0)] = 0`。 +- 検証は `server/scripts/verify.sh` を実行する(ruff check(FAST002) / ruff format / mypy / import+OpenAPI 生成の + 全項目がゲート)。リファクタ前後で走らせて回帰がないことを確認する。CI (`ci_server.yml`) でも同スクリプトを実行する。 + ### 2. データベース操作 - SQLAlchemyのORM使用 diff --git a/.github/workflows/ci_server.yml b/.github/workflows/ci_server.yml index b7a2595d..3e1229e6 100644 --- a/.github/workflows/ci_server.yml +++ b/.github/workflows/ci_server.yml @@ -90,6 +90,10 @@ jobs: working-directory: ./server run: uv sync + - name: Lint / type-check / smoke (ruff + mypy + FastAPI import) + working-directory: ./server + run: ./scripts/verify.sh + - name: Run Alembic migrations working-directory: ./server run: dotenvx run --env-file ../.env -- uv run alembic -n testdb upgrade head diff --git a/server/app/cruds/blocks.py b/server/app/cruds/blocks.py index 4a551f48..ac02a8ec 100644 --- a/server/app/cruds/blocks.py +++ b/server/app/cruds/blocks.py @@ -62,10 +62,7 @@ async def create_block(db: AsyncSession, block: BlockCreate, page_id: int) -> Bl # location等のリレーションを事前取得(Eager Load)して返し直す stmt = ( select(Block) - .options( - selectinload(Block.location), - selectinload(Block.destination_location) - ) + .options(selectinload(Block.location), selectinload(Block.destination_location)) .where(Block.id == db_block.id) ) result = await db.execute(stmt) @@ -84,9 +81,7 @@ async def find_blocks(db: AsyncSession, page_id: int) -> list[Block]: Returns: list[Block]: ブロックリスト """ - result = await db.execute( - _block_with_relations().where(Block.page_id == page_id) - ) + result = await db.execute(_block_with_relations().where(Block.page_id == page_id)) return list(result.scalars().all()) diff --git a/server/app/cruds/notification.py b/server/app/cruds/notification.py index f977a12c..b2c82688 100644 --- a/server/app/cruds/notification.py +++ b/server/app/cruds/notification.py @@ -138,9 +138,7 @@ async def delete_sent_notifications_for_block( return result.rowcount or 0 -async def delete_sent_notifications_for_page( - db: AsyncSession, *, page_id: int -) -> int: +async def delete_sent_notifications_for_page(db: AsyncSession, *, page_id: int) -> int: """指定 page 配下 block の送信済み予約を全て削除する。 Page.date が変更されると配下の全 block の絶対日時が動くため、まとめて再通知させる。 @@ -272,7 +270,9 @@ async def list_notification_candidates(db: AsyncSession) -> list[NotificationCan filtered: list[dict] = [] for r in rows: - absolute = _compose_absolute_start(r["page_date"], r["start_time"], r["timezone"]) + absolute = _compose_absolute_start( + r["page_date"], r["start_time"], r["timezone"] + ) if absolute is None: continue threshold = now_utc + timedelta(minutes=r["minutes_before"]) @@ -284,11 +284,11 @@ async def list_notification_candidates(db: AsyncSession) -> list[NotificationCan return [] location_ids: set[int] = set() - for r in filtered: - if r["location_id"] is not None: - location_ids.add(r["location_id"]) - if r["destination_location_id"] is not None: - location_ids.add(r["destination_location_id"]) + for item in filtered: + if item["location_id"] is not None: + location_ids.add(item["location_id"]) + if item["destination_location_id"] is not None: + location_ids.add(item["destination_location_id"]) location_name_map: dict[int, str] = {} if location_ids: diff --git a/server/app/cruds/pages.py b/server/app/cruds/pages.py index 83fe4098..5e8eba32 100644 --- a/server/app/cruds/pages.py +++ b/server/app/cruds/pages.py @@ -45,9 +45,7 @@ async def find_pages(db: AsyncSession, trip_id: int) -> list[Page]: Returns: list[Page]: ページリスト """ - result = await db.execute( - _page_with_relations().where(Page.trip_id == trip_id) - ) + result = await db.execute(_page_with_relations().where(Page.trip_id == trip_id)) return list(result.scalars().all()) diff --git a/server/app/cruds/trips.py b/server/app/cruds/trips.py index 2b8d0ecc..0a691890 100644 --- a/server/app/cruds/trips.py +++ b/server/app/cruds/trips.py @@ -8,7 +8,9 @@ def _trip_with_relations(): return select(Trip).options( - selectinload(Trip.pages).selectinload(Page.blocks).options( + selectinload(Trip.pages) + .selectinload(Page.blocks) + .options( selectinload(Block.location), selectinload(Block.destination_location), ) diff --git a/server/app/notification_content.py b/server/app/notification_content.py index c04fa1d3..3bdc3c38 100644 --- a/server/app/notification_content.py +++ b/server/app/notification_content.py @@ -15,7 +15,9 @@ def format_title(start_time: datetime, subscriber_timezone: str) -> str: return f"next {local.strftime('%H:%M')}" -def _format_location_line(location_name: str | None, destination_name: str | None) -> str | None: +def _format_location_line( + location_name: str | None, destination_name: str | None +) -> str | None: """場所行 (body の 2 行目) を組み立て。両方 null なら None を返して省略する。 現状 UI では destination_name は常に null (destination_location を設定する UI 未実装) だが、 diff --git a/server/app/oidc.py b/server/app/oidc.py index 64cf6d51..7220bd64 100644 --- a/server/app/oidc.py +++ b/server/app/oidc.py @@ -35,7 +35,10 @@ def verify_cloud_scheduler_oidc(request: Request) -> None: settings = get_settings() if settings.notify_tick_dev_bypass_oidc and settings.environment == "development": - logger.warning("OIDC verification bypassed (dev flag): environment=%s", settings.environment) + logger.warning( + "OIDC verification bypassed (dev flag): environment=%s", + settings.environment, + ) return if ( diff --git a/server/app/routers/blocks.py b/server/app/routers/blocks.py index 986752f4..a4d8c263 100644 --- a/server/app/routers/blocks.py +++ b/server/app/routers/blocks.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Annotated from fastapi import APIRouter, Depends @@ -43,7 +44,7 @@ async def get_blocks( _: Annotated[int, Depends(require_page_access)], db: Annotated[AsyncSession, Depends(get_db_session)], page_id: int, -) -> list[Block]: +) -> Sequence[Block]: """ 説明: diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index 98f7c2a5..d8f336a4 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Annotated from fastapi import APIRouter, Depends @@ -44,7 +45,7 @@ async def get_pages( _: Annotated[int, Depends(require_trip_access)], db: Annotated[AsyncSession, Depends(get_db_session)], trip_id: int, -) -> list[Page]: +) -> Sequence[Page]: """ 説明: diff --git a/server/app/routers/trips.py b/server/app/routers/trips.py index 33e2f033..a8514827 100644 --- a/server/app/routers/trips.py +++ b/server/app/routers/trips.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Annotated from fastapi import APIRouter, Depends, Request, Response @@ -50,7 +51,7 @@ async def create_trip( async def list_trips( _: Annotated[None, Depends(require_basic_auth)], db: Annotated[AsyncSession, Depends(get_db_session)], -) -> list[Trip]: +) -> Sequence[Trip]: """ 説明: diff --git a/server/app/schemas/location.py b/server/app/schemas/location.py index 921a646d..1c6cd4af 100644 --- a/server/app/schemas/location.py +++ b/server/app/schemas/location.py @@ -10,12 +10,16 @@ class LocationBase(BaseModel): - google_place_id: str | None = Field(default=None, max_length=GOOGLE_PLACE_ID_MAX_LENGTH) + google_place_id: str | None = Field( + default=None, max_length=GOOGLE_PLACE_ID_MAX_LENGTH + ) name: str = Field(min_length=1, max_length=LOCATION_MAX_NAME_LENGTH) address: str | None = None latitude: float | None = None longitude: float | None = None - website_uri: AnyHttpUrl | None = Field(default=None, max_length=WEBSITE_URI_MAX_LENGTH) + website_uri: AnyHttpUrl | None = Field( + default=None, max_length=WEBSITE_URI_MAX_LENGTH + ) @field_serializer("website_uri") def _serialize_website_uri(self, v: AnyHttpUrl | None) -> str | None: diff --git a/server/app/schemas/trip.py b/server/app/schemas/trip.py index 6dac626f..273ad0ae 100644 --- a/server/app/schemas/trip.py +++ b/server/app/schemas/trip.py @@ -26,9 +26,7 @@ def _check_walica_url(cls, value: str | None) -> str | None: return value parsed = urlparse(value) if parsed.scheme not in ("http", "https") or parsed.hostname != WALICA_HOSTNAME: - raise ValueError( - "walica_url must be an http(s) URL on walica.jp" - ) + raise ValueError("walica_url must be an http(s) URL on walica.jp") return value @model_validator(mode="after") diff --git a/server/pyproject.toml b/server/pyproject.toml index cee040f1..62b9b181 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "pytest>=8.4.1", "pytest-asyncio>=1.1.0", "ruff>=0.12.7", + "types-nanoid>=2.0.0.20260508", "types-passlib>=1.7.7.20250602", ] [tool.pytest.ini_options] @@ -36,3 +37,15 @@ asyncio_default_fixture_loop_scope = "function" [tool.ruff] exclude = ["migrations/versions"] + +[tool.ruff.lint] +# 既定 (E, F) に加え、FastAPI の依存/パラメータを Annotated 形式で書くことを強制する。 +# FAST002: `x: T = Depends(...)` / `x: T = Query(...)` を禁止し `x: Annotated[T, ...]` を必須化。 +# ※ FAST001 (冗長な response_model) / FAST003 は既存コードに多数該当するため今は有効化しない。 +extend-select = ["FAST002"] + +[tool.mypy] +# firebase-admin は型スタブ・py.typed マーカーを配布していないため import-untyped を無視する。 +[[tool.mypy.overrides]] +module = "firebase_admin.*" +ignore_missing_imports = true diff --git a/server/scripts/verify.sh b/server/scripts/verify.sh new file mode 100755 index 00000000..666360c5 --- /dev/null +++ b/server/scripts/verify.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# リファクタリング用の検証ハーネス。 +# DB を起動せずに「壊れていないか」「スタイル規約を満たすか」を高速に確認する。 +# 全項目 (ruff check / ruff format / mypy / smoke) はゲート = 失敗したら非0で終了する。 +# +# 実行内容: +# 1. ruff check … lint。FAST002 により非-Annotated な依存/パラメータを検出する +# 2. ruff format … フォーマット差分の有無 (--check) +# 3. mypy … 型チェック +# 4. smoke … ダミー DB URL でアプリを import し OpenAPI を生成できるか +# (FastAPI は依存注入グラフとシグネチャを登録/スキーマ生成時に検証するため、 +# Annotated の誤りや引数順の破綻はここで必ず落ちる。DB 接続は不要) +# +# 使い方: server/ ディレクトリで `./scripts/verify.sh` +# 終了コード: いずれか失敗で非0 (CI にそのまま組み込める) + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +fail=0 + +# ゲート: ベースラインでクリーンな項目。失敗したら非0で終了する。 +gate() { + local label="$1"; shift + echo "=== [gate] ${label} ===" + if "$@"; then + echo " ✓ ${label}" + else + echo " ✗ ${label} FAILED" + fail=1 + fi + echo "" +} + +gate "ruff check (FAST002 で Annotated 強制)" uv run ruff check app/ +gate "ruff format --check" uv run ruff format --check app/ +gate "mypy" uv run mypy app/ + +echo "=== smoke: import + OpenAPI 生成 ===" +if DATABASE_URL="postgresql://u:p@localhost:5432/dummy" \ + NOTIFICATIONS_ENABLED=false \ + COOKIE_SECRET_KEY=dummy \ + API_DOCS_USERNAME=dummy \ + API_DOCS_PASSWORD=dummy \ + uv run python -c "from app.main import app; n=len(app.openapi()['paths']); assert n>0; print(f' OpenAPI OK: {n} paths')"; then + echo " ✓ smoke" +else + echo " ✗ smoke FAILED" + fail=1 +fi +echo "" + +if [ "$fail" -eq 0 ]; then + echo "✅ すべての検証をパスしました" +else + echo "❌ 検証に失敗した項目があります" +fi +exit "$fail" diff --git a/server/uv.lock b/server/uv.lock index d5097954..0c759f0b 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -1097,6 +1097,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, + { name = "types-nanoid" }, { name = "types-passlib" }, ] @@ -1125,6 +1126,7 @@ dev = [ { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, { name = "ruff", specifier = ">=0.12.7" }, + { name = "types-nanoid", specifier = ">=2.0.0.20260508" }, { name = "types-passlib", specifier = ">=1.7.7.20250602" }, ] @@ -1170,6 +1172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, ] +[[package]] +name = "types-nanoid" +version = "2.0.0.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/9a/a9719ba0866c76c378d9ced7c59f76d98da987e8c971f2102aefbefd694b/types_nanoid-2.0.0.20260508.tar.gz", hash = "sha256:4ab493a2bc6dea7aa3c9671e2cb32ed0ed8f6419250a98ec533d49bc619302d7", size = 7351, upload-time = "2026-05-08T04:48:54.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/18/5a305d16ca4d43ce04c63a2c89b2da14de3b555f546d7f31b167a7f2ed55/types_nanoid-2.0.0.20260508-py3-none-any.whl", hash = "sha256:d6d3098c075ac1ba8e9e010e20605ffadf7a3870022eb19517868dc7566579c9", size = 8604, upload-time = "2026-05-08T04:48:53.257Z" }, +] + [[package]] name = "types-passlib" version = "1.7.7.20250602"