Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
version: 2
updates:
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "weekly"
groups:
python-minor-patch:
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
update-types:
- "minor"
- "patch"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9.0.0
- run: uv sync
- run: uv run ruff check .
- run: uv run ruff format --check .

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9.0.0
- run: uv sync
- run: uv run pytest
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
id-token: write

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- uses: google-github-actions/auth@v3
with:
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
fail_fast: true
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
rev: v0.16.0
hooks:
- id: ruff
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: local
Expand Down
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.12
3.14
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
FROM python:3.12-slim
FROM python:3.14-slim

WORKDIR /app

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY --from=ghcr.io/astral-sh/uv:0.11.32 /uv /usr/local/bin/uv

# Copy dependency files
COPY pyproject.toml uv.lock* ./
Expand Down
2 changes: 1 addition & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def cors_origin_regex(self) -> str | None:
return r"https://([a-z0-9-]+\.)?criticalbit\.gg"

@model_validator(mode="after")
def validate_production_settings(self) -> "Settings":
def validate_production_settings(self) -> Settings:
if not self.is_development:
if "postgres:postgres@" in self.database_url:
raise ValueError("Default database credentials must not be used in production")
Expand Down
2 changes: 1 addition & 1 deletion app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class Base(DeclarativeBase):
)


async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async def get_async_session() -> AsyncGenerator[AsyncSession]:
"""Dependency that provides an async database session."""
async with async_session_maker() as session:
yield session
4 changes: 2 additions & 2 deletions app/models/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Inventory(Base):
base_int: Mapped[int | None] = mapped_column(Integer, nullable=True)
base_agi: Mapped[int | None] = mapped_column(Integer, nullable=True)

items: Mapped[list["InventoryItem"]] = relationship(
items: Mapped[list[InventoryItem]] = relationship(
back_populates="inventory", cascade="all, delete-orphan", lazy="selectin"
)

Expand Down Expand Up @@ -53,4 +53,4 @@ class InventoryItem(Base):
pp_current: Mapped[int | None] = mapped_column(Integer, nullable=True)
pp_max: Mapped[int | None] = mapped_column(Integer, nullable=True)

inventory: Mapped["Inventory"] = relationship(back_populates="items")
inventory: Mapped[Inventory] = relationship(back_populates="items")
2 changes: 1 addition & 1 deletion app/schemas/game_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ class EnemyDetailRead(BaseModel):
movement: int = 0
is_boss: bool = False
body_parts: list[EnemyBodyPartRead] = []
drops: list["EnemyDropRead"] = []
drops: list[EnemyDropRead] = []
encounters: list[EnemyEncounterRead] = []

model_config = {"from_attributes": True, "populate_by_name": True}
Expand Down
14 changes: 12 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "vagrant-story-api"
version = "0.3.0"
description = "Public game data API for Vagrant Story"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.135.0",
"uvicorn[standard]>=0.41.0",
Expand Down Expand Up @@ -38,7 +38,7 @@ testpaths = ["tests"]
asyncio_default_fixture_loop_scope = "function"

[tool.ruff]
target-version = "py312"
target-version = "py314"
line-length = 100

[tool.ruff.lint]
Expand All @@ -58,3 +58,13 @@ ignore = [

[tool.ruff.lint.isort]
known-first-party = ["app"]

[tool.uv]
# FastAPI >=0.137 changed how included routers' routes are resolved, which
# breaks slowapi's SlowAPIMiddleware enforcement of `default_limits` on
# undecorated routes mounted via include_router (verified here: the global
# rate-limit canary fails on 0.140.x and passes on 0.135.x). Known upstream
# as slowapi issue #281 with fix PR #282; drop this hold once a slowapi
# release containing that fix ships. Starlette is capped alongside it since
# slowapi has never been validated against Starlette 1.x.
constraint-dependencies = ["fastapi<0.136", "starlette<1.0"]
17 changes: 14 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


@pytest.fixture(autouse=True)
def reset_rate_limiter():
"""Clear rate-limit buckets between tests so request counts don't leak
across tests (the limiter storage is process-global)."""
from app.rate_limit import limiter

limiter._storage.reset()
yield
limiter._storage.reset()


@pytest.fixture(autouse=True)
async def setup_database():
async with engine.begin() as conn:
Expand All @@ -22,7 +33,7 @@ async def setup_database():
await conn.run_sync(Base.metadata.drop_all)


async def override_get_async_session() -> AsyncGenerator[AsyncSession, None]:
async def override_get_async_session() -> AsyncGenerator[AsyncSession]:
async with async_session_maker() as session:
yield session

Expand All @@ -31,7 +42,7 @@ async def override_get_async_session() -> AsyncGenerator[AsyncSession, None]:


@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
async def client() -> AsyncGenerator[AsyncClient]:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
Expand All @@ -40,6 +51,6 @@ async def client() -> AsyncGenerator[AsyncClient, None]:


@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
async def session() -> AsyncGenerator[AsyncSession]:
async with async_session_maker() as session:
yield session
27 changes: 27 additions & 0 deletions tests/test_rate_limiting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Global rate limiting behaviour.

The Limiter's ``default_limits`` (60/min, enforced by SlowAPIMiddleware)
must apply to every route that isn't decorated or exempt, and the
infrastructure routes must stay exempt. This is the canary for the class
of regression where a framework upgrade silently disables middleware
enforcement (see slowapi issue #281 for fastapi>=0.137).
"""

from httpx import AsyncClient


class TestGlobalRateLimit:
async def test_default_limit_enforced_on_undecorated_route(self, client: AsyncClient):
# /v1/armor has no explicit @limiter.limit, so it gets the 60/min
# default. (Matches `default_limits` in app/rate_limit.py — bump
# both together if that changes.)
for _ in range(60):
assert (await client.get("/v1/armor")).status_code == 200
assert (await client.get("/v1/armor")).status_code == 429

async def test_infrastructure_routes_are_exempt(self, client: AsyncClient):
# / and /health are @limiter.exempt — many rapid hits (well past
# the 60/min default) never 429.
for _ in range(70):
assert (await client.get("/health")).status_code == 200
assert (await client.get("/")).status_code == 200
Loading