feat!: adopt Python 3.15 and support BattlesnakeArena

- Require Python 3.15 in project metadata, runtime pin, and Docker stages.
- Use lazy imports for database backends and type-only dependencies.
- Defer benchmark dependencies until their code paths are used.
- Rely on deferred annotations and use Path.info for file discovery.
- Accept BattlesnakeArena alongside BattlesnakeEngine on game endpoints.
- Advance quart_common for Python 3.15 and multi-agent validation.
- Add pytest development dependencies and refresh the uv lockfile.
- Test lazy backend loading, import order, and fresh file discovery.
- Document the runtime requirements and locked uv workflow.

BREAKING CHANGE: Python 3.15 or newer is required; earlier runtimes cannot parse the lazy import syntax.
This commit is contained in:
2026-11-03 10:02:40 +01:00
parent f14d780f29
commit 5efab77248
35 changed files with 226 additions and 202 deletions
+5 -6
View File
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, cast
from typing import cast
import asyncio, json, time, os
from quart import Blueprint, request, jsonify
@@ -10,8 +10,7 @@ from snakes import DEFAULT_SNAKE_CONFIG
from server.GameBoard import GameBoard
from server.Files import read_file
if TYPE_CHECKING:
from server.Server import Server
lazy from server.Server import Server
def create_battlesnake_blueprint(server:'Server') -> Blueprint:
blueprint = Blueprint('battlesnake', __name__)
@@ -45,7 +44,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
return snake_json
@blueprint.post('/start')
@require_user_agent("BattlesnakeEngine", abort_code=404)
@require_user_agent(("BattlesnakeEngine", "BattlesnakeArena"), abort_code=404)
async def on_start():
server.metrics_collector.record_http_request('start')
await server.game_runtime.prune_stale_games()
@@ -59,7 +58,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
return 'ok'
@blueprint.post('/move')
@require_user_agent("BattlesnakeEngine", abort_code=404)
@require_user_agent(("BattlesnakeEngine", "BattlesnakeArena"), abort_code=404)
async def on_move():
server.metrics_collector.record_http_request('move')
game_state = await request.get_json()
@@ -91,7 +90,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
return {'move': next_move}
@blueprint.post('/end')
@require_user_agent("BattlesnakeEngine", abort_code=404)
@require_user_agent(("BattlesnakeEngine", "BattlesnakeArena"), abort_code=404)
async def on_end():
server.metrics_collector.record_http_request('end')
await server.game_runtime.prune_stale_games()
+1 -3
View File
@@ -1,4 +1,3 @@
from typing import TYPE_CHECKING
import asyncio, json, os
from quart import (
@@ -9,8 +8,7 @@ from quart import (
websocket,
)
if TYPE_CHECKING:
from server.Server import Server
lazy from server.Server import Server
def create_dashboard_blueprint(server:'Server') -> Blueprint:
blueprint = Blueprint('dashboard', __name__)
+1 -3
View File
@@ -1,8 +1,6 @@
from quart import Blueprint, jsonify, request
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from server.Server import Server
lazy from server.Server import Server
def create_metrics_blueprint(server:'Server') -> Blueprint:
blueprint = Blueprint('metrics', __name__)
+1 -4
View File
@@ -1,7 +1,4 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from server.GameBoard import GameBoard
lazy from server.GameBoard import GameBoard
from server.dataset.Dataset import Dataset
from server.Files import save_file
+5 -4
View File
@@ -1,15 +1,17 @@
from .Template import GameplayBackendTemplate
lazy from .PostgresqlGameplayBackend import PostgresqlGameplayBackend as PostgresqlBackend
lazy from .SqliteGameplayBackend import SqliteGameplayBackend as SqliteBackend
class GameplayBackendBuilder:
@staticmethod
def build(backend:str="sqlite", db_path:str|None=None, busy_timeout_ms:int=5000, pg_dsn:str|None=None, pg_min_size:int=1, pg_max_size:int=5) -> GameplayBackendTemplate:
normalized = (backend or "sqlite").strip().lower()
if normalized == "postgresql" or normalized == "postgres":
from .PostgresqlGameplayBackend import PostgresqlGameplayBackend
if not pg_dsn:
raise ValueError("pg_dsn is required for the postgresql backend")
return PostgresqlGameplayBackend(
return PostgresqlBackend(
dsn=pg_dsn,
min_size=pg_min_size,
max_size=pg_max_size,
@@ -17,9 +19,8 @@ class GameplayBackendBuilder:
)
if normalized == "sqlite":
from .SqliteGameplayBackend import SqliteGameplayBackend
if not db_path:
raise ValueError("db_path is required for the sqlite backend")
return SqliteGameplayBackend(db_path=db_path, busy_timeout_ms=busy_timeout_ms)
return SqliteBackend(db_path=db_path, busy_timeout_ms=busy_timeout_ms)
raise ValueError(f"Unknown gameplay backend: {backend!r}. Choose 'sqlite' or 'postgresql'.")
-3
View File
@@ -1,7 +1,4 @@
"""Load sampled gameplay positions from SQLite or PostgreSQL."""
from __future__ import annotations
import asyncio
import json
from urllib.parse import urlparse
+1 -4
View File
@@ -1,7 +1,4 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from server.GameBoard import GameBoard
lazy from server.GameBoard import GameBoard
class Dataset:
VALID_MOVES = {"up", "down", "left", "right"}
+3 -3
View File
@@ -16,7 +16,7 @@ class DatasetIO:
for item in input_files:
path = Path(item)
if path.is_dir():
if path.info.is_dir():
for pattern in patterns:
for file_path in sorted(path.rglob(pattern)):
key = str(file_path.resolve())
@@ -29,7 +29,7 @@ class DatasetIO:
if any(ch in item for ch in "*?[]"):
for match in sorted(glob.glob(item)):
file_path = Path(match)
if not file_path.is_file():
if not file_path.info.is_file():
continue
key = str(file_path.resolve())
if key in seen:
@@ -38,7 +38,7 @@ class DatasetIO:
resolved.append(file_path)
continue
if path.is_file():
if path.info.is_file():
key = str(path.resolve())
if key in seen:
continue