refactor(backend): migrate type checking from mypy to pyright

Replace mypy>=1.8.0 with pyright>=1.1.390. Remove all [tool.mypy] and
[tool.pydantic-mypy] sections from pyproject.toml and add
pyrightconfig.json (standard mode, SQLAlchemy false-positive rules
suppressed globally).

Fixes surfaced by pyright:
- Remove unreachable except AuthError clauses in login/login_oauth (same class as AuthenticationError)
- Fix Pydantic v2 list Field: min_items/max_items → min_length/max_length
- Split OAuthProviderConfig TypedDict into required + optional(email_url) inheritance
- Move JWTError/ExpiredSignatureError from lazy try-block imports to module level
- Add timezone-aware guard to UserSession.is_expired to match sibling models
- Fix is_active: bool → bool | None in three organization repo signatures
- Initialize search_filter = None before conditional block (possibly unbound fix)
- Add bool() casts to model is_expired and repo is_active/is_superuser returns
- Restructure except (JWTError, Exception) into separate except clauses
This commit is contained in:
2026-02-28 19:12:40 +01:00
parent 4c6bf55bcc
commit a8aa416ecb
17 changed files with 85 additions and 201 deletions

View File

@@ -14,7 +14,7 @@ help:
@echo " make lint-fix - Run Ruff linter with auto-fix" @echo " make lint-fix - Run Ruff linter with auto-fix"
@echo " make format - Format code with Ruff" @echo " make format - Format code with Ruff"
@echo " make format-check - Check if code is formatted" @echo " make format-check - Check if code is formatted"
@echo " make type-check - Run mypy type checking" @echo " make type-check - Run pyright type checking"
@echo " make validate - Run all checks (lint + format + types)" @echo " make validate - Run all checks (lint + format + types)"
@echo "" @echo ""
@echo "Testing:" @echo "Testing:"
@@ -63,8 +63,8 @@ format-check:
@uv run ruff format --check app/ tests/ @uv run ruff format --check app/ tests/
type-check: type-check:
@echo "🔎 Running mypy type checking..." @echo "🔎 Running pyright type checking..."
@uv run mypy app/ @uv run pyright app/
validate: lint format-check type-check validate: lint format-check type-check
@echo "✅ All quality checks passed!" @echo "✅ All quality checks passed!"
@@ -127,7 +127,7 @@ clean:
@echo "🧹 Cleaning up..." @echo "🧹 Cleaning up..."
@find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
@find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".pyright" -exec rm -rf {} + 2>/dev/null || true
@find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
@find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
@find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true @find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true

View File

@@ -65,7 +65,7 @@ class BulkUserAction(BaseModel):
action: BulkAction = Field(..., description="Action to perform on selected users") action: BulkAction = Field(..., description="Action to perform on selected users")
user_ids: list[UUID] = Field( user_ids: list[UUID] = Field(
..., min_items=1, max_items=100, description="List of user IDs (max 100)" ..., min_length=1, max_length=100, description="List of user IDs (max 100)"
) )

View File

@@ -183,9 +183,6 @@ async def login(
# Handle specific authentication errors like inactive accounts # Handle specific authentication errors like inactive accounts
logger.warning(f"Authentication failed: {e!s}") logger.warning(f"Authentication failed: {e!s}")
raise AuthError(message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS) raise AuthError(message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS)
except AuthError:
# Re-raise custom auth exceptions without modification
raise
except Exception as e: except Exception as e:
# Handle unexpected errors # Handle unexpected errors
logger.error(f"Unexpected error during login: {e!s}", exc_info=True) logger.error(f"Unexpected error during login: {e!s}", exc_info=True)
@@ -232,9 +229,6 @@ async def login_oauth(
except AuthenticationError as e: except AuthenticationError as e:
logger.warning(f"OAuth authentication failed: {e!s}") logger.warning(f"OAuth authentication failed: {e!s}")
raise AuthError(message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS) raise AuthError(message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS)
except AuthError:
# Re-raise custom auth exceptions without modification
raise
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during OAuth login: {e!s}", exc_info=True) logger.error(f"Unexpected error during OAuth login: {e!s}", exc_info=True)
raise DatabaseError( raise DatabaseError(

View File

@@ -655,7 +655,7 @@ async def introspect(
) )
except Exception as e: except Exception as e:
logger.warning(f"Token introspection error: {e}") logger.warning(f"Token introspection error: {e}")
return OAuthTokenIntrospectionResponse(active=False) return OAuthTokenIntrospectionResponse(active=False) # pyright: ignore[reportCallIssue]
# ============================================================================ # ============================================================================

View File

@@ -222,7 +222,7 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
) )
error_response = ErrorResponse( error_response = ErrorResponse(
errors=[ErrorDetail(code=error_code, message=str(exc.detail))] errors=[ErrorDetail(code=error_code, message=str(exc.detail), field=None)]
) )
return JSONResponse( return JSONResponse(
@@ -254,7 +254,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
message = f"{type(exc).__name__}: {exc!s}" message = f"{type(exc).__name__}: {exc!s}"
error_response = ErrorResponse( error_response = ErrorResponse(
errors=[ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=message)] errors=[ErrorDetail(code=ErrorCode.INTERNAL_ERROR, message=message, field=None)]
) )
return JSONResponse( return JSONResponse(

View File

@@ -92,7 +92,7 @@ class OAuthAuthorizationCode(Base, UUIDMixin, TimestampMixin):
# Handle both timezone-aware and naive datetimes from DB # Handle both timezone-aware and naive datetimes from DB
if expires_at.tzinfo is None: if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC) expires_at = expires_at.replace(tzinfo=UTC)
return now > expires_at return bool(now > expires_at)
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:

View File

@@ -99,7 +99,7 @@ class OAuthProviderRefreshToken(Base, UUIDMixin, TimestampMixin):
# Handle both timezone-aware and naive datetimes from DB # Handle both timezone-aware and naive datetimes from DB
if expires_at.tzinfo is None: if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC) expires_at = expires_at.replace(tzinfo=UTC)
return now > expires_at return bool(now > expires_at)
@property @property
def is_valid(self) -> bool: def is_valid(self) -> bool:

View File

@@ -76,7 +76,11 @@ class UserSession(Base, UUIDMixin, TimestampMixin):
"""Check if session has expired.""" """Check if session has expired."""
from datetime import datetime from datetime import datetime
return self.expires_at < datetime.now(UTC) now = datetime.now(UTC)
expires_at = self.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
return bool(expires_at < now)
def to_dict(self): def to_dict(self):
"""Convert session to dictionary for serialization.""" """Convert session to dictionary for serialization."""

View File

@@ -174,6 +174,7 @@ class OrganizationRepository(
if is_active is not None: if is_active is not None:
query = query.where(Organization.is_active == is_active) query = query.where(Organization.is_active == is_active)
search_filter = None
if search: if search:
search_filter = or_( search_filter = or_(
Organization.name.ilike(f"%{search}%"), Organization.name.ilike(f"%{search}%"),
@@ -185,7 +186,7 @@ class OrganizationRepository(
count_query = select(func.count(Organization.id)) count_query = select(func.count(Organization.id))
if is_active is not None: if is_active is not None:
count_query = count_query.where(Organization.is_active == is_active) count_query = count_query.where(Organization.is_active == is_active)
if search: if search_filter is not None:
count_query = count_query.where(search_filter) count_query = count_query.where(search_filter)
count_result = await db.execute(count_query) count_result = await db.execute(count_query)
@@ -333,7 +334,7 @@ class OrganizationRepository(
organization_id: UUID, organization_id: UUID,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
is_active: bool = True, is_active: bool | None = True,
) -> tuple[list[dict[str, Any]], int]: ) -> tuple[list[dict[str, Any]], int]:
"""Get members of an organization with user details.""" """Get members of an organization with user details."""
try: try:
@@ -387,7 +388,7 @@ class OrganizationRepository(
raise raise
async def get_user_organizations( async def get_user_organizations(
self, db: AsyncSession, *, user_id: UUID, is_active: bool = True self, db: AsyncSession, *, user_id: UUID, is_active: bool | None = True
) -> list[Organization]: ) -> list[Organization]:
"""Get all organizations a user belongs to.""" """Get all organizations a user belongs to."""
try: try:
@@ -410,7 +411,7 @@ class OrganizationRepository(
raise raise
async def get_user_organizations_with_details( async def get_user_organizations_with_details(
self, db: AsyncSession, *, user_id: UUID, is_active: bool = True self, db: AsyncSession, *, user_id: UUID, is_active: bool | None = True
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get user's organizations with role and member count in SINGLE QUERY.""" """Get user's organizations with role and member count in SINGLE QUERY."""
try: try:
@@ -476,7 +477,7 @@ class OrganizationRepository(
) )
user_org = result.scalar_one_or_none() user_org = result.scalar_one_or_none()
return user_org.role if user_org else None return user_org.role if user_org else None # pyright: ignore[reportReturnType]
except Exception as e: except Exception as e:
logger.error(f"Error getting user role in org: {e!s}") logger.error(f"Error getting user role in org: {e!s}")
raise raise

View File

@@ -256,11 +256,11 @@ class UserRepository(BaseRepository[User, UserCreate, UserUpdate]):
def is_active(self, user: User) -> bool: def is_active(self, user: User) -> bool:
"""Check if user is active.""" """Check if user is active."""
return user.is_active return bool(user.is_active)
def is_superuser(self, user: User) -> bool: def is_superuser(self, user: User) -> bool:
"""Check if user is a superuser.""" """Check if user is a superuser."""
return user.is_superuser return bool(user.is_superuser)
# Singleton instance # Singleton instance

View File

@@ -48,7 +48,7 @@ class OrganizationCreate(OrganizationBase):
"""Schema for creating a new organization.""" """Schema for creating a new organization."""
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
slug: str = Field(..., min_length=1, max_length=255) slug: str = Field(..., min_length=1, max_length=255) # pyright: ignore[reportIncompatibleVariableOverride]
class OrganizationUpdate(BaseModel): class OrganizationUpdate(BaseModel):

View File

@@ -25,7 +25,8 @@ from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from jose import jwt from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
@@ -677,8 +678,6 @@ async def revoke_token(
# Try as access token (JWT) # Try as access token (JWT)
if token_type_hint != "refresh_token": if token_type_hint != "refresh_token":
try: try:
from jose.exceptions import JWTError
payload = jwt.decode( payload = jwt.decode(
token, token,
settings.SECRET_KEY, settings.SECRET_KEY,
@@ -700,7 +699,9 @@ async def revoke_token(
f"Revoked refresh token via access token JTI {jti[:8]}..." f"Revoked refresh token via access token JTI {jti[:8]}..."
) )
return True return True
except (JWTError, Exception): # noqa: S110 - Intentional: invalid JWT not an error except JWTError:
pass
except Exception: # noqa: S110 - Intentional: invalid JWT not an error
pass pass
return False return False
@@ -791,8 +792,6 @@ async def introspect_token(
# Try as access token (JWT) first # Try as access token (JWT) first
if token_type_hint != "refresh_token": if token_type_hint != "refresh_token":
try: try:
from jose.exceptions import ExpiredSignatureError, JWTError
payload = jwt.decode( payload = jwt.decode(
token, token,
settings.SECRET_KEY, settings.SECRET_KEY,
@@ -823,7 +822,9 @@ async def introspect_token(
} }
except ExpiredSignatureError: except ExpiredSignatureError:
return {"active": False} return {"active": False}
except (JWTError, Exception): # noqa: S110 - Intentional: invalid JWT falls through to refresh token check except JWTError:
pass
except Exception: # noqa: S110 - Intentional: invalid JWT falls through to refresh token check
pass pass
# Try as refresh token # Try as refresh token

View File

@@ -39,19 +39,22 @@ from app.schemas.oauth import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OAuthProviderConfig(TypedDict, total=False): class _OAuthProviderConfigRequired(TypedDict):
"""Type definition for OAuth provider configuration."""
name: str name: str
icon: str icon: str
authorize_url: str authorize_url: str
token_url: str token_url: str
userinfo_url: str userinfo_url: str
email_url: str # Optional, GitHub-only
scopes: list[str] scopes: list[str]
supports_pkce: bool supports_pkce: bool
class OAuthProviderConfig(_OAuthProviderConfigRequired, total=False):
"""Type definition for OAuth provider configuration."""
email_url: str # Optional, GitHub-only
# Provider configurations # Provider configurations
OAUTH_PROVIDERS: dict[str, OAuthProviderConfig] = { OAUTH_PROVIDERS: dict[str, OAuthProviderConfig] = {
"google": { "google": {
@@ -485,7 +488,7 @@ class OAuthService:
# GitHub requires separate request for email # GitHub requires separate request for email
if provider == "github" and not user_info.get("email"): if provider == "github" and not user_info.get("email"):
email_resp = await client.get( email_resp = await client.get(
config["email_url"], config["email_url"], # pyright: ignore[reportTypedDictNotRequiredAccess]
headers=headers, headers=headers,
) )
email_resp.raise_for_status() email_resp.raise_for_status()

View File

@@ -65,10 +65,10 @@ async def setup_async_test_db():
async with test_engine.begin() as conn: async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
AsyncTestingSessionLocal = sessionmaker( AsyncTestingSessionLocal = sessionmaker( # pyright: ignore[reportCallIssue]
autocommit=False, autocommit=False,
autoflush=False, autoflush=False,
bind=test_engine, bind=test_engine, # pyright: ignore[reportArgumentType]
expire_on_commit=False, expire_on_commit=False,
class_=AsyncSession, class_=AsyncSession,
) )

View File

@@ -72,7 +72,7 @@ dev = [
# Development tools # Development tools
"ruff>=0.8.0", # All-in-one: linting, formatting, import sorting "ruff>=0.8.0", # All-in-one: linting, formatting, import sorting
"mypy>=1.8.0", # Type checking "pyright>=1.1.390", # Type checking
] ]
# E2E testing with real PostgreSQL (requires Docker) # E2E testing with real PostgreSQL (requires Docker)
@@ -185,120 +185,6 @@ indent-style = "space"
skip-magic-trailing-comma = false skip-magic-trailing-comma = false
line-ending = "lf" line-ending = "lf"
# ============================================================================
# mypy Configuration - Type Checking
# ============================================================================
[tool.mypy]
python_version = "3.12"
warn_return_any = false # SQLAlchemy queries return Any - overly strict
warn_unused_configs = true
disallow_untyped_defs = false # Gradual typing - enable later
disallow_incomplete_defs = false
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
strict_equality = true
ignore_missing_imports = false
explicit_package_bases = true
namespace_packages = true
# Pydantic plugin for better validation
plugins = ["pydantic.mypy"]
# Per-module options
[[tool.mypy.overrides]]
module = "alembic.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "app.alembic.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "sqlalchemy.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "fastapi_utils.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "slowapi.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "jose.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "passlib.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "pydantic_settings.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "fastapi.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "apscheduler.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "starlette.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "authlib.*"
ignore_missing_imports = true
# SQLAlchemy ORM models - Column descriptors cause type confusion
[[tool.mypy.overrides]]
module = "app.models.*"
disable_error_code = ["assignment", "arg-type", "return-value"]
# CRUD operations - Generic ModelType and SQLAlchemy Result issues
[[tool.mypy.overrides]]
module = "app.crud.*"
disable_error_code = ["attr-defined", "assignment", "arg-type", "return-value"]
# API routes - SQLAlchemy Column to Pydantic schema conversions
[[tool.mypy.overrides]]
module = "app.api.routes.*"
disable_error_code = ["arg-type", "call-arg", "call-overload", "assignment"]
# API dependencies - Similar SQLAlchemy Column issues
[[tool.mypy.overrides]]
module = "app.api.dependencies.*"
disable_error_code = ["arg-type"]
# FastAPI exception handlers have correct signatures despite mypy warnings
[[tool.mypy.overrides]]
module = "app.main"
disable_error_code = ["arg-type"]
# Auth service - SQLAlchemy Column issues
[[tool.mypy.overrides]]
module = "app.services.auth_service"
disable_error_code = ["assignment", "arg-type"]
# Test utils - Testing patterns
[[tool.mypy.overrides]]
module = "app.utils.auth_test_utils"
disable_error_code = ["assignment", "arg-type"]
# ============================================================================
# Pydantic mypy plugin configuration
# ============================================================================
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
# ============================================================================ # ============================================================================
# Pytest Configuration # Pytest Configuration
# ============================================================================ # ============================================================================

View File

@@ -0,0 +1,23 @@
{
"include": ["app"],
"exclude": ["app/alembic"],
"pythonVersion": "3.12",
"venvPath": ".",
"venv": ".venv",
"typeCheckingMode": "standard",
"reportMissingImports": true,
"reportMissingTypeStubs": false,
"reportUnknownMemberType": false,
"reportUnknownVariableType": false,
"reportUnknownArgumentType": false,
"reportUnknownParameterType": false,
"reportUnknownLambdaType": false,
"reportReturnType": true,
"reportUnusedImport": false,
"reportGeneralTypeIssues": false,
"reportAttributeAccessIssue": false,
"reportArgumentType": false,
"strictListInference": false,
"strictDictionaryInference": false,
"strictSetInference": false
}

66
backend/uv.lock generated
View File

@@ -519,7 +519,7 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "freezegun" }, { name = "freezegun" },
{ name = "mypy" }, { name = "pyright" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
@@ -546,12 +546,12 @@ requires-dist = [
{ name = "fastapi-utils", specifier = "==0.8.0" }, { name = "fastapi-utils", specifier = "==0.8.0" },
{ name = "freezegun", marker = "extra == 'dev'", specifier = "~=1.5.1" }, { name = "freezegun", marker = "extra == 'dev'", specifier = "~=1.5.1" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
{ name = "passlib", specifier = "==1.7.4" }, { name = "passlib", specifier = "==1.7.4" },
{ name = "pillow", specifier = ">=10.3.0" }, { name = "pillow", specifier = ">=10.3.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.9" }, { name = "psycopg2-binary", specifier = ">=2.9.9" },
{ name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic", specifier = ">=2.10.6" },
{ name = "pydantic-settings", specifier = ">=2.2.1" }, { name = "pydantic-settings", specifier = ">=2.2.1" },
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.390" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
@@ -966,44 +966,12 @@ wheels = [
] ]
[[package]] [[package]]
name = "mypy" name = "nodeenv"
version = "1.18.2" version = "1.10.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
{ url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" },
{ url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" },
{ url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" },
{ url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" },
{ url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" },
{ url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" },
{ url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" },
{ url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" },
{ url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" },
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" },
]
[[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]] [[package]]
@@ -1024,15 +992,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
] ]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.0.0" version = "12.0.0"
@@ -1302,6 +1261,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/af/d8bf0959ece9bc4679bd203908c31019556a421d76d8143b0c6871c7f614/pyrate_limiter-3.9.0-py3-none-any.whl", hash = "sha256:77357840c8cf97a36d67005d4e090787043f54000c12c2b414ff65657653e378", size = 33628, upload-time = "2025-07-30T14:36:57.71Z" }, { url = "https://files.pythonhosted.org/packages/04/af/d8bf0959ece9bc4679bd203908c31019556a421d76d8143b0c6871c7f614/pyrate_limiter-3.9.0-py3-none-any.whl", hash = "sha256:77357840c8cf97a36d67005d4e090787043f54000c12c2b414ff65657653e378", size = 33628, upload-time = "2025-07-30T14:36:57.71Z" },
] ]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.2" version = "8.4.2"