Skip to content
Merged
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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,4 @@ jobs:

- run: ruff check .

- name: mypy (advisory, not gating)
run: mypy velocix --ignore-missing-imports || true
- run: mypy velocix
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dev = [
"ruff>=0.5.0",
"black>=23.0.0",
"isort>=5.12.0",
"types-aiofiles>=23.0.0",
]

[tool.black]
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions velocix/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ""
Expand All @@ -1038,7 +1042,7 @@ async def redoc_handler(request: Request) -> HTMLResponse:
<style>body {{ margin:0; padding:0; }}</style>
</head>
<body>
<redoc spec-url="{_html.escape(self.openapi_url)}"></redoc>
<redoc spec-url="{_html.escape(openapi_url)}"></redoc>
<script src="{REDOC_JS_URL}"{js_integrity}></script>
</body>
</html>"""
Expand Down
2 changes: 1 addition & 1 deletion velocix/core/depends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions velocix/core/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 5 additions & 3 deletions velocix/security/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
9 changes: 7 additions & 2 deletions velocix/security/brute_force.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion velocix/security/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any, Literal

import jwt
from jwt.types import Options


class TokenBlacklist:
Expand Down Expand Up @@ -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,
Expand Down
Loading