feat: beartype runtime type checking + test infrastructure improvements (#619)

* fix: enable beartype runtime type checking and fix type violations

enables beartype for runtime type checking across the backend package.
this catches type violations at function call time, improving reliability.

**type fixes:**
- `_get_existing_track_order`: accept `str | None` for album_atproto_uri
- `_emit_copyright_label`: use `int` for highest_score (matches db model)
- `ModerationClient.__init__`: accept `int | float` for timeout_seconds
- `UploadProgressTracker`: accept `int | float` for min_time_between_updates
- `hash_file_chunked`: use `BinaryIO | IOBase` (works with BytesIO and file handles)
- `build_track_record` callers: guard against None r2_url before calling

**test fixes:**
- `MockStorage`: inherit from `R2Storage` for proper type compatibility
- `test_update_album_title`: add `r2_url` to track fixture

**refactors:**
- `storage/__init__.py`: import `R2Storage` directly (no lazy forward ref)
- `image.py`, `audio.py`: use `typing.Self` for classmethod return types
- `auth.py`: import `EllipticCurvePrivateKey` directly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add beartype as explicit dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: disable automatic perpetual task scheduling in tests

The docket worker's automatic perpetual task scheduling was causing
event loop issues during test teardown. The Worker creates async
connections that get attached to one event loop, but TestClient
teardown runs on a different loop.

Added DOCKET_SCHEDULE_AUTOMATIC_TASKS setting (default: true) and
set it to false in test environment to prevent this issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* perf: session-scope TestClient fixture for 5x faster tests

The client fixture was function-scoped, causing the full FastAPI
lifespan (database init, services, docket worker) to run for each
test. Switching to session-scope reduces test_stats.py from 26s to 5s.

Full test suite now runs in ~17s instead of potentially much longer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove init_db() from lifespan

init_db() called Base.metadata.create_all on every server start.
This was a no-op since all tables already exist in dev/staging/prod.
Tests handle their own table creation via conftest.py.

Dead code removed. Database schema is managed by alembic migrations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 6d1ec368 f10724ce

+3
backend/pyproject.toml
··· 29 "mutagen>=1.47.0", 30 "pydocket>=0.15.2", 31 "redis>=7.1.0", 32 ] 33 34 requires-python = ">=3.11" ··· 84 # redis URL for cache tests (uses test-redis from docker-compose) 85 # D: prefix means don't override if already set (e.g., by CI workflow) 86 "D:DOCKET_URL=redis://localhost:6380/0", 87 ] 88 markers = [ 89 "integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
··· 29 "mutagen>=1.47.0", 30 "pydocket>=0.15.2", 31 "redis>=7.1.0", 32 + "beartype>=0.22.8", 33 ] 34 35 requires-python = ">=3.11" ··· 85 # redis URL for cache tests (uses test-redis from docker-compose) 86 # D: prefix means don't override if already set (e.g., by CI workflow) 87 "D:DOCKET_URL=redis://localhost:6380/0", 88 + # disable automatic perpetual task scheduling in tests to avoid event loop issues 89 + "DOCKET_SCHEDULE_AUTOMATIC_TASKS=false", 90 ] 91 markers = [ 92 "integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
+5
backend/src/backend/__init__.py
··· 1 def hello() -> str: 2 return "Hello from backend!"
··· 1 + from beartype.claw import beartype_this_package 2 + 3 + beartype_this_package() 4 + 5 + 6 def hello() -> str: 7 return "Hello from backend!"
+1 -1
backend/src/backend/_internal/atproto/sync.py
··· 17 18 19 async def _get_existing_track_order( 20 - album_atproto_uri: str, 21 artist_pds_url: str | None, 22 ) -> list[str]: 23 """fetch existing track URIs from ATProto list record.
··· 17 18 19 async def _get_existing_track_order( 20 + album_atproto_uri: str | None, 21 artist_pds_url: str | None, 22 ) -> list[str]: 23 """fetch existing track URIs from ATProto list record.
+2 -1
backend/src/backend/_internal/audio.py
··· 1 """audio file type definitions.""" 2 3 from enum import Enum 4 5 6 class AudioFormat(str, Enum): ··· 26 return media_types[self] 27 28 @classmethod 29 - def from_extension(cls, ext: str) -> "AudioFormat | None": 30 """get format from file extension (with or without dot).""" 31 ext = ext.lower().lstrip(".") 32 for format in cls:
··· 1 """audio file type definitions.""" 2 3 from enum import Enum 4 + from typing import Self 5 6 7 class AudioFormat(str, Enum): ··· 27 return media_types[self] 28 29 @classmethod 30 + def from_extension(cls, ext: str) -> Self | None: 31 """get format from file extension (with or without dot).""" 32 ext = ext.lower().lstrip(".") 33 for format in cls:
+4 -6
backend/src/backend/_internal/auth.py
··· 5 import secrets 6 from dataclasses import dataclass 7 from datetime import UTC, datetime, timedelta 8 - from typing import TYPE_CHECKING, Annotated, Any 9 10 from atproto_oauth import OAuthClient 11 from atproto_oauth.stores.memory import MemorySessionStore 12 from cryptography.fernet import Fernet 13 from cryptography.hazmat.primitives.asymmetric import ec 14 from cryptography.hazmat.primitives.serialization import load_pem_private_key 15 from fastapi import Cookie, Header, HTTPException 16 from jose import jwk ··· 20 from backend.config import settings 21 from backend.models import ExchangeToken, PendingDevToken, UserPreferences, UserSession 22 from backend.utilities.database import db_session 23 - 24 - if TYPE_CHECKING: 25 - from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey 26 27 logger = logging.getLogger(__name__) 28 ··· 76 _session_store = MemorySessionStore() 77 78 # confidential client key (loaded lazily) 79 - _client_secret_key: "EllipticCurvePrivateKey | None" = None 80 _client_secret_kid: str | None = None 81 _client_secret_key_loaded = False 82 83 84 - def _load_client_secret() -> tuple["EllipticCurvePrivateKey | None", str | None]: 85 """load EC private key and kid from OAUTH_JWK setting for confidential client. 86 87 the key is expected to be a JSON-serialized JWK with ES256 (P-256) key.
··· 5 import secrets 6 from dataclasses import dataclass 7 from datetime import UTC, datetime, timedelta 8 + from typing import Annotated, Any 9 10 from atproto_oauth import OAuthClient 11 from atproto_oauth.stores.memory import MemorySessionStore 12 from cryptography.fernet import Fernet 13 from cryptography.hazmat.primitives.asymmetric import ec 14 + from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey 15 from cryptography.hazmat.primitives.serialization import load_pem_private_key 16 from fastapi import Cookie, Header, HTTPException 17 from jose import jwk ··· 21 from backend.config import settings 22 from backend.models import ExchangeToken, PendingDevToken, UserPreferences, UserSession 23 from backend.utilities.database import db_session 24 25 logger = logging.getLogger(__name__) 26 ··· 74 _session_store = MemorySessionStore() 75 76 # confidential client key (loaded lazily) 77 + _client_secret_key: EllipticCurvePrivateKey | None = None 78 _client_secret_kid: str | None = None 79 _client_secret_key_loaded = False 80 81 82 + def _load_client_secret() -> tuple[EllipticCurvePrivateKey | None, str | None]: 83 """load EC private key and kid from OAUTH_JWK setting for confidential client. 84 85 the key is expected to be a JSON-serialized JWK with ES256 (P-256) key.
+2
backend/src/backend/_internal/background.py
··· 77 scheduling_resolution=timedelta( 78 seconds=settings.docket.scheduling_resolution_seconds 79 ), 80 ) as worker: 81 worker_task = asyncio.create_task( 82 worker.run_forever(),
··· 77 scheduling_resolution=timedelta( 78 seconds=settings.docket.scheduling_resolution_seconds 79 ), 80 + # disable automatic perpetual tasks in tests to avoid event loop issues 81 + schedule_automatic_tasks=settings.docket.schedule_automatic_tasks, 82 ) as worker: 83 worker_task = asyncio.create_task( 84 worker.run_forever(),
+4 -3
backend/src/backend/_internal/image.py
··· 1 """image format handling for media storage.""" 2 3 from enum import Enum 4 5 6 class ImageFormat(str, Enum): ··· 24 }[self.value] 25 26 @classmethod 27 - def from_filename(cls, filename: str) -> "ImageFormat | None": 28 """extract image format from filename extension.""" 29 ext = filename.lower().split(".")[-1] 30 if ext in ["jpg", "jpeg"]: ··· 34 return None 35 36 @classmethod 37 - def from_content_type(cls, content_type: str | None) -> "ImageFormat | None": 38 """extract image format from MIME content type. 39 40 this is more reliable than filename extension, especially on iOS ··· 56 @classmethod 57 def validate_and_extract( 58 cls, filename: str | None, content_type: str | None = None 59 - ) -> tuple["ImageFormat | None", bool]: 60 """validate image format from filename or content type. 61 62 prefers content_type over filename extension when available, since
··· 1 """image format handling for media storage.""" 2 3 from enum import Enum 4 + from typing import Self 5 6 7 class ImageFormat(str, Enum): ··· 25 }[self.value] 26 27 @classmethod 28 + def from_filename(cls, filename: str) -> Self | None: 29 """extract image format from filename extension.""" 30 ext = filename.lower().split(".")[-1] 31 if ext in ["jpg", "jpeg"]: ··· 35 return None 36 37 @classmethod 38 + def from_content_type(cls, content_type: str | None) -> Self | None: 39 """extract image format from MIME content type. 40 41 this is more reliable than filename extension, especially on iOS ··· 57 @classmethod 58 def validate_and_extract( 59 cls, filename: str | None, content_type: str | None = None 60 + ) -> tuple[Self | None, bool]: 61 """validate image format from filename or content type. 62 63 prefers content_type over filename extension when available, since
+1 -1
backend/src/backend/_internal/moderation.py
··· 107 track_title: str | None = None, 108 artist_handle: str | None = None, 109 artist_did: str | None = None, 110 - highest_score: float | None = None, 111 matches: list[dict[str, Any]] | None = None, 112 ) -> None: 113 """emit a copyright-violation label to the ATProto labeler service."""
··· 107 track_title: str | None = None, 108 artist_handle: str | None = None, 109 artist_did: str | None = None, 110 + highest_score: int | None = None, 111 matches: list[dict[str, Any]] | None = None, 112 ) -> None: 113 """emit a copyright-violation label to the ATProto labeler service."""
+1 -1
backend/src/backend/_internal/moderation_client.py
··· 54 service_url: str, 55 labeler_url: str, 56 auth_token: str, 57 - timeout_seconds: float, 58 label_cache_prefix: str, 59 label_cache_ttl_seconds: int, 60 ) -> None:
··· 54 service_url: str, 55 labeler_url: str, 56 auth_token: str, 57 + timeout_seconds: float | int, 58 label_cache_prefix: str, 59 label_cache_ttl_seconds: int, 60 ) -> None:
+18 -17
backend/src/backend/api/albums.py
··· 517 track.extra = {} 518 track.extra = {**track.extra, "album": new_title} 519 520 - # update ATProto record 521 - updated_record = build_track_record( 522 - title=track.title, 523 - artist=track.artist.display_name, 524 - audio_url=track.r2_url, 525 - file_type=track.file_type, 526 - album=new_title, 527 - duration=track.duration, 528 - features=track.features if track.features else None, 529 - image_url=await track.get_image_url(), 530 - ) 531 532 - _, new_cid = await update_record( 533 - auth_session=auth_session, 534 - record_uri=track.atproto_record_uri, 535 - record=updated_record, 536 - ) 537 - track.atproto_record_cid = new_cid 538 539 # update the album's ATProto list record name 540 if album.atproto_record_uri:
··· 517 track.extra = {} 518 track.extra = {**track.extra, "album": new_title} 519 520 + # update ATProto record if track has one 521 + if track.atproto_record_uri and track.r2_url and track.file_type: 522 + updated_record = build_track_record( 523 + title=track.title, 524 + artist=track.artist.display_name, 525 + audio_url=track.r2_url, 526 + file_type=track.file_type, 527 + album=new_title, 528 + duration=track.duration, 529 + features=track.features if track.features else None, 530 + image_url=await track.get_image_url(), 531 + ) 532 533 + _, new_cid = await update_record( 534 + auth_session=auth_session, 535 + record_uri=track.atproto_record_uri, 536 + record=updated_record, 537 + ) 538 + track.atproto_record_cid = new_cid 539 540 # update the album's ATProto list record name 541 if album.atproto_record_uri:
+4
backend/src/backend/config.py
··· 597 default=5.0, 598 description="How often to run the scheduler loop (seconds). Default 5s reduces Redis costs vs docket's 250ms default.", 599 ) 600 601 602 class RateLimitSettings(AppSettingsSection):
··· 597 default=5.0, 598 description="How often to run the scheduler loop (seconds). Default 5s reduces Redis costs vs docket's 250ms default.", 599 ) 600 + schedule_automatic_tasks: bool = Field( 601 + default=True, 602 + description="Schedule automatic perpetual tasks at worker startup. Disable in tests to avoid event loop issues.", 603 + ) 604 605 606 class RateLimitSettings(AppSettingsSection):
-7
backend/src/backend/main.py
··· 46 from backend.api.lists import router as lists_router 47 from backend.api.migration import router as migration_router 48 from backend.config import settings 49 - from backend.models import init_db 50 from backend.utilities.rate_limit import limiter 51 52 # configure logfire if enabled ··· 148 @asynccontextmanager 149 async def lifespan(app: FastAPI) -> AsyncIterator[None]: 150 """handle application lifespan events.""" 151 - # startup: initialize database 152 - # NOTE: init_db() is still needed because base tables (artists, tracks, user_sessions) 153 - # don't have migrations - they were created before migrations were introduced. 154 - # See issue #46 for removing this in favor of a proper initial migration. 155 - await init_db() 156 - 157 # setup services 158 await notification_service.setup() 159 await queue_service.setup()
··· 46 from backend.api.lists import router as lists_router 47 from backend.api.migration import router as migration_router 48 from backend.config import settings 49 from backend.utilities.rate_limit import limiter 50 51 # configure logfire if enabled ··· 147 @asynccontextmanager 148 async def lifespan(app: FastAPI) -> AsyncIterator[None]: 149 """handle application lifespan events.""" 150 # setup services 151 await notification_service.setup() 152 await queue_service.setup()
+1 -2
backend/src/backend/models/__init__.py
··· 18 from backend.models.track import Track 19 from backend.models.track_comment import TrackComment 20 from backend.models.track_like import TrackLike 21 - from backend.utilities.database import db_session, get_db, init_db 22 23 __all__ = [ 24 "Album", ··· 42 "UserSession", 43 "db_session", 44 "get_db", 45 - "init_db", 46 ]
··· 18 from backend.models.track import Track 19 from backend.models.track_comment import TrackComment 20 from backend.models.track_like import TrackLike 21 + from backend.utilities.database import db_session, get_db 22 23 __all__ = [ 24 "Album", ··· 42 "UserSession", 43 "db_session", 44 "get_db", 45 ]
+3 -8
backend/src/backend/storage/__init__.py
··· 1 """storage implementations.""" 2 3 - from typing import TYPE_CHECKING 4 5 - if TYPE_CHECKING: 6 - from backend.storage.r2 import R2Storage 7 8 - _storage: "R2Storage | None" = None 9 10 - 11 - def _get_storage() -> "R2Storage": 12 """lazily initialize storage on first access.""" 13 global _storage 14 if _storage is None: 15 - from backend.storage.r2 import R2Storage 16 - 17 _storage = R2Storage() 18 return _storage 19
··· 1 """storage implementations.""" 2 3 + from backend.storage.r2 import R2Storage 4 5 + _storage: R2Storage | None = None 6 7 8 + def _get_storage() -> R2Storage: 9 """lazily initialize storage on first access.""" 10 global _storage 11 if _storage is None: 12 _storage = R2Storage() 13 return _storage 14
+1 -1
backend/src/backend/storage/r2.py
··· 32 total_size: int, 33 callback: Callable[[float], None], 34 min_bytes_between_updates: int = 5 * 1024 * 1024, # 5MB 35 - min_time_between_updates: float = 0.25, # 250ms 36 ): 37 """initialize progress tracker. 38
··· 32 total_size: int, 33 callback: Callable[[float], None], 34 min_bytes_between_updates: int = 5 * 1024 * 1024, # 5MB 35 + min_time_between_updates: float | int = 0.25, # 250ms 36 ): 37 """initialize progress tracker. 38
-9
backend/src/backend/utilities/database.py
··· 90 """get async database session (for FastAPI dependency injection).""" 91 async with db_session() as session: 92 yield session 93 - 94 - 95 - async def init_db(): 96 - """initialize database tables.""" 97 - from backend.models.database import Base 98 - 99 - engine = get_engine() 100 - async with engine.begin() as conn: 101 - await conn.run_sync(Base.metadata.create_all)
··· 90 """get async database session (for FastAPI dependency injection).""" 91 async with db_session() as session: 92 yield session
+2 -1
backend/src/backend/utilities/hashing.py
··· 1 """streaming hash calculation utilities.""" 2 3 import hashlib 4 from typing import BinaryIO 5 6 # 8MB chunks balances memory usage and performance 7 CHUNK_SIZE = 8 * 1024 * 1024 8 9 10 - def hash_file_chunked(file_obj: BinaryIO, algorithm: str = "sha256") -> str: 11 """compute hash by reading file in chunks. 12 13 this prevents loading entire file into memory, enabling constant
··· 1 """streaming hash calculation utilities.""" 2 3 import hashlib 4 + from io import IOBase 5 from typing import BinaryIO 6 7 # 8MB chunks balances memory usage and performance 8 CHUNK_SIZE = 8 * 1024 * 1024 9 10 11 + def hash_file_chunked(file_obj: BinaryIO | IOBase, algorithm: str = "sha256") -> str: 12 """compute hash by reading file in chunks. 13 14 this prevents loading entire file into memory, enabling constant
+2 -1
backend/tests/api/test_albums.py
··· 657 track = Track( 658 title="Test Track", 659 file_id="test-file-update", 660 - file_type="audio/mpeg", 661 artist_did=artist.did, 662 album_id=album.id, 663 extra={"album": "Original Title"}, 664 atproto_record_uri="at://did:test:user123/fm.plyr.track/track123", 665 atproto_record_cid="original_cid", 666 )
··· 657 track = Track( 658 title="Test Track", 659 file_id="test-file-update", 660 + file_type="mp3", 661 artist_did=artist.did, 662 album_id=album.id, 663 extra={"album": "Original Title"}, 664 + r2_url="https://r2.example.com/audio/test-file-update.mp3", 665 atproto_record_uri="at://did:test:user123/fm.plyr.track/track123", 666 atproto_record_cid="original_cid", 667 )
+22 -8
backend/tests/conftest.py
··· 22 23 from backend.config import settings 24 from backend.models import Base 25 from backend.utilities.redis import clear_client_cache 26 27 28 - class MockStorage: 29 """Mock storage for tests - no R2 credentials needed.""" 30 31 async def save(self, file_obj, filename: str, progress_callback=None) -> str: 32 """Mock save - returns a fake file_id.""" 33 return "mock_file_id_123" 34 35 async def get_url( 36 - self, file_id: str, file_type: str | None = None, extension: str | None = None 37 - ) -> str: 38 """Mock get_url - returns a fake URL.""" 39 return f"https://mock.r2.dev/{file_id}" 40 41 - async def delete(self, file_id: str, extension: str | None = None) -> None: 42 """Mock delete.""" 43 44 45 def pytest_configure(config): ··· 359 yield session 360 361 362 - @pytest.fixture 363 def fastapi_app() -> FastAPI: 364 - """provides the FastAPI app instance.""" 365 from backend.main import app as main_app 366 367 return main_app 368 369 370 - @pytest.fixture 371 def client(fastapi_app: FastAPI) -> Generator[TestClient, None, None]: 372 - """provides a TestClient for testing the FastAPI application.""" 373 with TestClient(fastapi_app) as tc: 374 yield tc 375
··· 22 23 from backend.config import settings 24 from backend.models import Base 25 + from backend.storage.r2 import R2Storage 26 from backend.utilities.redis import clear_client_cache 27 28 29 + class MockStorage(R2Storage): 30 """Mock storage for tests - no R2 credentials needed.""" 31 32 + def __init__(self): 33 + # skip R2Storage.__init__ which requires credentials 34 + pass 35 + 36 async def save(self, file_obj, filename: str, progress_callback=None) -> str: 37 """Mock save - returns a fake file_id.""" 38 return "mock_file_id_123" 39 40 async def get_url( 41 + self, 42 + file_id: str, 43 + *, 44 + file_type: str | None = None, 45 + extension: str | None = None, 46 + ) -> str | None: 47 """Mock get_url - returns a fake URL.""" 48 return f"https://mock.r2.dev/{file_id}" 49 50 + async def delete(self, file_id: str, file_type: str | None = None) -> bool: 51 """Mock delete.""" 52 + return True 53 54 55 def pytest_configure(config): ··· 369 yield session 370 371 372 + @pytest.fixture(scope="session") 373 def fastapi_app() -> FastAPI: 374 + """provides the FastAPI app instance (session-scoped for performance).""" 375 from backend.main import app as main_app 376 377 return main_app 378 379 380 + @pytest.fixture(scope="session") 381 def client(fastapi_app: FastAPI) -> Generator[TestClient, None, None]: 382 + """provides a TestClient for testing the FastAPI application. 383 + 384 + session-scoped to avoid the overhead of starting the full lifespan 385 + (database init, services, docket worker) for each test. 386 + """ 387 with TestClient(fastapi_app) as tc: 388 yield tc 389
+2
backend/uv.lock
··· 317 { name = "alembic" }, 318 { name = "asyncpg" }, 319 { name = "atproto" }, 320 { name = "boto3" }, 321 { name = "cachetools" }, 322 { name = "fastapi" }, ··· 363 { name = "alembic", specifier = ">=1.14.0" }, 364 { name = "asyncpg", specifier = ">=0.30.0" }, 365 { name = "atproto", git = "https://github.com/zzstoatzz/atproto?rev=main" }, 366 { name = "boto3", specifier = ">=1.37.0" }, 367 { name = "cachetools", specifier = ">=6.2.1" }, 368 { name = "fastapi", specifier = ">=0.115.0" },
··· 317 { name = "alembic" }, 318 { name = "asyncpg" }, 319 { name = "atproto" }, 320 + { name = "beartype" }, 321 { name = "boto3" }, 322 { name = "cachetools" }, 323 { name = "fastapi" }, ··· 364 { name = "alembic", specifier = ">=1.14.0" }, 365 { name = "asyncpg", specifier = ">=0.30.0" }, 366 { name = "atproto", git = "https://github.com/zzstoatzz/atproto?rev=main" }, 367 + { name = "beartype", specifier = ">=0.22.8" }, 368 { name = "boto3", specifier = ">=1.37.0" }, 369 { name = "cachetools", specifier = ">=6.2.1" }, 370 { name = "fastapi", specifier = ">=0.115.0" },
+112
docs/research/2025-12-19-beartype.md
···
··· 1 + # research: beartype runtime type checking 2 + 3 + **date**: 2025-12-19 4 + **question**: investigate beartype for runtime type checking, determine how to integrate into plyr.fm 5 + 6 + ## summary 7 + 8 + beartype is a runtime type checker that validates Python type hints at execution time with O(1) worst-case performance. it's already a transitive dependency via `py-key-value-aio`. FastMCP does **not** use beartype. integration would require adding `beartype_this_package()` to `backend/src/backend/__init__.py`. 9 + 10 + ## findings 11 + 12 + ### what beartype does 13 + 14 + - validates type hints at runtime when functions are called 15 + - O(1) non-amortized worst-case time (constant time regardless of data structure size) 16 + - zero runtime dependencies, pure Python 17 + - MIT license 18 + 19 + ### key integration patterns 20 + 21 + **package-wide (recommended)**: 22 + ```python 23 + # At the very top of backend/src/backend/__init__.py 24 + from beartype.claw import beartype_this_package 25 + beartype_this_package() # enables type-checking for all submodules 26 + ``` 27 + 28 + **per-function**: 29 + ```python 30 + from beartype import beartype 31 + 32 + @beartype 33 + def my_function(x: int) -> str: 34 + return str(x) 35 + ``` 36 + 37 + ### configuration options (`BeartypeConf`) 38 + 39 + key parameters: 40 + - `violation_type` - exception class to raise (default: `BeartypeCallHintViolation`) 41 + - `violation_param_type` - exception for parameter violations 42 + - `violation_return_type` - exception for return type violations 43 + - `strategy` - checking strategy (default: `O1` for O(1) time) 44 + - `is_debug` - enable debugging output 45 + - `claw_skip_package_names` - packages to exclude from type checking 46 + 47 + **example with warnings for third-party code**: 48 + ```python 49 + from beartype import BeartypeConf 50 + from beartype.claw import beartype_all, beartype_this_package 51 + 52 + beartype_this_package() # strict for our code 53 + beartype_all(conf=BeartypeConf(violation_type=UserWarning)) # warn for third-party 54 + ``` 55 + 56 + ### current state in plyr.fm 57 + 58 + beartype is already installed as a transitive dependency: 59 + - `backend/uv.lock:477-482` - beartype 0.22.8 present 60 + - pulled in by `py-key-value-aio` and `py-key-value-shared` 61 + 62 + ### FastMCP status 63 + 64 + FastMCP does **not** use beartype: 65 + - not in FastMCP's dependencies 66 + - FastMCP uses type hints for schema generation/documentation, not runtime validation 67 + 68 + ### integration approach for plyr.fm 69 + 70 + 1. **add explicit dependency** (optional but good for clarity): 71 + ```toml 72 + # pyproject.toml 73 + dependencies = [ 74 + "beartype>=0.22.0", 75 + # ... existing deps 76 + ] 77 + ``` 78 + 79 + 2. **enable in `__init__.py`**: 80 + ```python 81 + # backend/src/backend/__init__.py 82 + from beartype.claw import beartype_this_package 83 + beartype_this_package() 84 + 85 + def hello() -> str: 86 + return "Hello from backend!" 87 + ``` 88 + 89 + 3. **considerations**: 90 + - must be called before importing any submodules 91 + - main.py currently imports warnings before filtering, then imports submodules 92 + - beartype should be activated in `__init__.py`, not `main.py` 93 + 94 + ### potential concerns 95 + 96 + 1. **performance**: O(1) guarantees should be fine, but worth benchmarking 97 + 2. **third-party compatibility**: some libraries may have inaccurate type hints; use `claw_skip_package_names` or warn mode 98 + 3. **FastAPI**: pydantic already validates request/response types; beartype adds internal function validation 99 + 100 + ## code references 101 + 102 + - `backend/uv.lock:477-482` - beartype 0.22.8 in lockfile 103 + - `backend/uv.lock:2240` - py-key-value-aio depends on beartype 104 + - `backend/uv.lock:2261` - py-key-value-shared depends on beartype 105 + - `backend/src/backend/__init__.py:1-2` - current init (needs modification) 106 + - `backend/src/backend/main.py:1-50` - app initialization (imports after warnings filter) 107 + 108 + ## open questions 109 + 110 + - should we enable strict mode (exceptions) or warning mode initially? 111 + - which third-party packages might have problematic type hints to skip? 112 + - should we benchmark API response times before/after enabling?