From beb88c6bff54db16eb562d4fa30ef320bf699134 Mon Sep 17 00:00:00 2001 From: magi Date: Sun, 6 Sep 2026 19:36:25 +0530 Subject: [PATCH 1/3] fix(types): clear all remaining mypy errors, make mypy gating in CI Closes issue #7's tracked debt (13 errors, 2 already fixed as a side effect of the router/openapi rewrite in #10): - depends.py: narrow Any | None before .__metadata__ access - jwt.py: type the options dict as jwt.types.Options instead of a bare dict - router.py: type handler dunder-attr assignment with the same # type: ignore[attr-defined] pattern app.py's own route() already uses - security/base.py: HookManager._hooks typed as list[SecurityHook] instead of list[Any], so on_request's declared Response | None return survives - brute_force.py: incr_sync/reset_sync are a MemoryBackend-only monkey-patch, not part of the async StorageBackend Protocol -- documented why, ignored the attr-defined error, and typed count explicitly so no-any-return clears - app.py: typed the raw ASGI scope headers list instead of leaving it Any; asserted the two docs handlers' already-guaranteed-non-None openapi_url mypy velocix --ignore-missing-imports: 0 errors. Full 252-test suite green. CI's mypy step is no longer advisory-only. --- .github/workflows/ci.yml | 3 +-- velocix/core/app.py | 8 ++++++-- velocix/core/depends.py | 2 +- velocix/core/router.py | 4 ++-- velocix/security/base.py | 8 +++++--- velocix/security/brute_force.py | 9 +++++++-- velocix/security/jwt.py | 3 ++- 7 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0233a4a..ddaff83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,4 @@ jobs: - run: ruff check . - - name: mypy (advisory, not gating) - run: mypy velocix --ignore-missing-imports || true + - run: mypy velocix --ignore-missing-imports diff --git a/velocix/core/app.py b/velocix/core/app.py index bf50269..e26baae 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -86,7 +86,8 @@ def _request_if_none_match( if request is not None: return request.headers.get(b"if-none-match") if scope is not None: - for k, v in scope.get("headers", []): + headers: list[tuple[bytes, bytes]] = scope.get("headers", []) + for k, v in headers: if k == b"if-none-match": return v return None @@ -974,6 +975,7 @@ async def openapi_handler(request: Request) -> Response: async def swagger_handler(request: Request) -> HTMLResponse: openapi_url = self.openapi_url + assert openapi_url is not None # guaranteed by the enclosing `if` above from velocix.openapi.generator import ( SWAGGER_CSS_SRI, SWAGGER_CSS_URL, @@ -1024,6 +1026,8 @@ async def swagger_handler(request: Request) -> HTMLResponse: if self.openapi_url and self.redoc_url: async def redoc_handler(request: Request) -> HTMLResponse: + openapi_url = self.openapi_url + assert openapi_url is not None # guaranteed by the enclosing `if` above from velocix.openapi.generator import REDOC_JS_SRI, REDOC_JS_URL js_integrity = f' integrity="{REDOC_JS_SRI}" crossorigin="anonymous"' if REDOC_JS_SRI else "" @@ -1038,7 +1042,7 @@ async def redoc_handler(request: Request) -> HTMLResponse: - + """ diff --git a/velocix/core/depends.py b/velocix/core/depends.py index 3bfc262..104e5f6 100644 --- a/velocix/core/depends.py +++ b/velocix/core/depends.py @@ -272,7 +272,7 @@ def _build_resolution_plan(handler: Callable[..., Any]) -> tuple[tuple[str, str, body_marker = None if isinstance(param_hint, type) and isinstance(getattr(param_hint, '__metadata__', None), tuple): pass - elif hasattr(param_hint, '__metadata__'): + elif param_hint is not None and hasattr(param_hint, '__metadata__'): for m in param_hint.__metadata__: if isinstance(m, Body): body_marker = m diff --git a/velocix/core/router.py b/velocix/core/router.py index b01a944..7e370b4 100644 --- a/velocix/core/router.py +++ b/velocix/core/router.py @@ -270,9 +270,9 @@ def include_router(self, router: "Router", prefix: str = "", tags: list[str] | N if tags: existing = getattr(handler, "__route_tags__", None) if existing: - handler.__route_tags__ = list(existing) + tags + handler.__route_tags__ = list(existing) + tags # type: ignore[attr-defined] else: - handler.__route_tags__ = tags + handler.__route_tags__ = tags # type: ignore[attr-defined] def url_path_for(self, name: str, /, **path_params: Any) -> str: """Build a URL path for a named route (reverse routing). diff --git a/velocix/security/base.py b/velocix/security/base.py index 2e33f3f..d8908b5 100644 --- a/velocix/security/base.py +++ b/velocix/security/base.py @@ -145,12 +145,14 @@ class HookManager(BaseMiddleware): def __init__( self, app: Callable[[Request], Awaitable[Response]], - hooks: list[Any] | None = None, + hooks: list[SecurityHook] | None = None, ) -> None: super().__init__(app) - self._hooks: list[Any] = sorted(hooks or [], key=lambda h: getattr(h, "priority", 100)) + self._hooks: list[SecurityHook] = sorted( + hooks or [], key=lambda h: getattr(h, "priority", 100) + ) - def add_hook(self, hook: Any) -> None: + def add_hook(self, hook: SecurityHook) -> None: """Add a hook and re-sort by priority.""" self._hooks.append(hook) self._hooks.sort(key=lambda h: getattr(h, "priority", 100)) diff --git a/velocix/security/brute_force.py b/velocix/security/brute_force.py index f40f4c0..a46fdef 100644 --- a/velocix/security/brute_force.py +++ b/velocix/security/brute_force.py @@ -147,14 +147,19 @@ def record_failure(self, key: str) -> int: If the count exceeds ``max_attempts``, the key is locked out. """ - count = self._backend.incr_sync(f"bf:{key}", self._window_seconds) + # incr_sync/reset_sync aren't part of the async StorageBackend Protocol — + # _patch_backend() monkey-patches them onto MemoryBackend only (its ops + # are plain dict access under the hood, so a sync shim is safe there). + # A backend that doesn't get patched (e.g. RedisBackend) would AttributeError + # here; see issue tracking async-backend support for record_failure/mark_success. + count: int = self._backend.incr_sync(f"bf:{key}", self._window_seconds) # type: ignore[attr-defined] if count >= self._max_attempts: self._lock(key) return count def mark_success(self, key: str) -> None: """Reset the failure counter and lockout for a key after successful auth.""" - self._backend.reset_sync(f"bf:{key}") + self._backend.reset_sync(f"bf:{key}") # type: ignore[attr-defined] self._lockouts.pop(key, None) def get_retry_after(self, key: str) -> int: diff --git a/velocix/security/jwt.py b/velocix/security/jwt.py index 7ea1b80..562dfbf 100644 --- a/velocix/security/jwt.py +++ b/velocix/security/jwt.py @@ -7,6 +7,7 @@ from typing import Any, Literal import jwt +from jwt.types import Options class TokenBlacklist: @@ -214,7 +215,7 @@ def decode(self, token: str, verify: bool = True) -> dict[str, Any]: if not key_to_use: raise ValueError("No key available for token verification") - options = {"verify_signature": verify} + options: Options = {"verify_signature": verify} payload = jwt.decode( token, From 8fbf3c153bdc49853f059e04b6ac348457b388f2 Mon Sep 17 00:00:00 2001 From: magi Date: Sun, 6 Sep 2026 19:41:09 +0530 Subject: [PATCH 2/3] fix(deps,types): install the actual missing type stubs, don't blanket-ignore ignore_missing_imports = true in [tool.mypy] was suppressing every missing stub, not just the ones that needed it. Removed it and dealt with what mypy actually found: - aiofiles: http/client.py's download() imports it unconditionally (not guarded), but it was never in requirements.txt -- only in pyproject.toml's dependency list. Anyone installing from requirements.txt alone (CI, and any app built on velocix) would ImportError the first time .download() ran. Added it for real, plus types-aiofiles for mypy. - yaml, redis: both are genuinely optional, try/except-ImportError-guarded integrations (openapi YAML export, RedisBackend), not installed by default. Scoped ignore_missing_imports to just those two modules instead of the whole codebase. mypy velocix (no --ignore-missing-imports anywhere): 0 errors. ruff and full 252-test suite still green. --- pyproject.toml | 8 ++++++++ requirements.txt | 2 ++ 2 files changed, 10 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fe5085f..e2ba789 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "ruff>=0.5.0", "black>=23.0.0", "isort>=5.12.0", + "types-aiofiles>=23.0.0", ] [tool.black] @@ -77,4 +78,11 @@ python_version = "3.11" check_untyped_defs = true warn_return_any = true warn_unused_configs = true + +# yaml (openapi/models.py, generator.py) and redis (security/base.py's +# RedisBackend) are both genuinely optional, try/except-ImportError-guarded +# integrations, not real requirements — not installed by default, so no +# blanket ignore_missing_imports for the whole codebase. +[[tool.mypy.overrides]] +module = ["yaml", "redis", "redis.*"] ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index 81d1846..5f2e85d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,8 @@ click>=8.1.8 xxhash>=3.6.0 itsdangerous>=2.0.0 nh3>=0.2.0 +aiofiles>=23.0.0 pytest>=7.4.0 mypy>=1.7.0 ruff>=0.5.0 +types-aiofiles>=23.0.0 From e6c342142ae5df3f05e6ebb0635f9ae6cb92088f Mon Sep 17 00:00:00 2001 From: magi Date: Sun, 6 Sep 2026 19:42:11 +0530 Subject: [PATCH 3/3] ci: drop --ignore-missing-imports now that mypy is actually clean The CLI flag would've silently re-blanket-suppressed everything the scoped pyproject.toml overrides no longer do. Verified in a fresh venv matching CI's exact install steps: mypy velocix, ruff check ., pytest all clean/green. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddaff83..f400594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,4 @@ jobs: - run: ruff check . - - run: mypy velocix --ignore-missing-imports + - run: mypy velocix