Skip to content

Commit 47fb9c5

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
release: 0.1.5 — code-review fixes (StreamingResponse double-exec, path-param coercion, GraphiQL SRI, FileFlagProvider race, --offline)
1 parent 44dab31 commit 47fb9c5

12 files changed

Lines changed: 147 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.1.5] - 2026-04-19
11+
12+
### Fixed
13+
14+
- **[HIGH] Double-execution of handler on `StreamingResponse`.** Routes whose handler returns a `StreamingResponse` / `FileResponse` were classified as trivial by the Wave 3 fast path, and a late fallback to `_execute_route` re-ran the handler — doubling any side effects (DB writes, emails, billing). `_compute_trivial` now inspects the handler's return annotation and excludes streaming returns at registration time; the fast path dispatches streaming responses in-place as a defensive guard.
15+
- **[MEDIUM] Path-param coercion missing on the trivial fast path.** `{id:int}`-style typed path params were passed as raw `str` to the handler, breaking the declared type contract. The fast path now calls `_coerce_fast` on path values (same behaviour as the general path).
16+
- **[MEDIUM] GraphiQL CDN assets now use pinned versions + Subresource Integrity (SRI) hashes.** Default `app.mount_graphql(...)` ships with `graphiql=True`; the embedded HTML loaded React/GraphiQL from `cdn.jsdelivr.net` with the `@3` / `@18` floating tags and without `integrity=` attributes — a supply-chain vector. All four assets are now pinned to exact versions (`graphiql@3.0.9`, `react@18.3.1`, `react-dom@18.3.1`) with `sha384` SRI hashes.
17+
- **[LOW] `FileFlagProvider` cache/mtime update order.** Under free-threaded CPython or thread-pool workers, writing `_mtime` before `_cache` could let a concurrent reader observe the new mtime, skip the reload, and return the stale cache. Cache is now written first, mtime last.
18+
- **[LOW] Lazy imports inside `_execute_trivial_route` hoisted out of the hot path.** `ParamSource` and `_coerce_fast` are now imported at module scope in `app.py` — a small but per-request saving on every trivial dispatch.
19+
20+
### Added
21+
22+
- `hawkapi doctor --offline` — skip rules that require network access (e.g. DOC050's PyPI version check). Rules opt in via `requires_network: bool = True`.
23+
- README `Security` section note: always use `secrets.compare_digest` to compare credentials returned by `HTTPBasic` / `HTTPBearer` to avoid timing attacks.
24+
25+
### Changed
26+
27+
- `build_mypyc.py` documents the MSVC reserved-identifier trap (`__is_trivial`, `__is_class`, `__is_base_of`, `__has_trivial_destructor`, …) so future additions to `HOT_MODULES` avoid `_is_*` / `_has_*` private attribute names that collide with C++11 type-trait keywords on Windows.
28+
- `[tool.ruff] extend-exclude` and `[tool.ruff.lint.per-file-ignores]` extended so local venvs, build artefacts, and non-library code (`benchmarks/**`, `examples/**`, `hatch_build.py`) no longer block lint.
29+
1030
## [0.1.4] - 2026-04-19
1131

1232
### Added

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ async def protected(credentials=Depends(auth)):
357357

358358
Built-in schemes: `HTTPBearer`, `HTTPBasic`, `APIKeyHeader`, `APIKeyQuery`, `APIKeyCookie`, `OAuth2PasswordBearer`.
359359

360+
> **Comparing credentials safely.** `HTTPBasic` / `HTTPBearer` only *extract* credentials; comparison against your stored secret is your responsibility. Always use a constant-time helper to avoid timing attacks:
361+
>
362+
> ```python
363+
> import secrets
364+
>
365+
> if not secrets.compare_digest(creds.password, stored_hash):
366+
> raise HTTPException(401, detail="Invalid credentials")
367+
> ```
368+
360369
#### Declarative Permissions (RBAC)
361370
362371
```python

build_mypyc.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@
3333
# subclassing at runtime, so compiling these would break the public API.
3434
# Compiling the radix tree, route record, param converters and middleware
3535
# pipeline still captures the dominant request-routing hot path.
36+
#
37+
# MSVC RESERVED IDENTIFIERS — when adding attributes to compiled classes,
38+
# avoid any name that starts with ``__is_`` or ``__has_`` and matches a C++11
39+
# type-trait keyword. mypyc transpiles ``_private`` attribute names to
40+
# ``__private`` in generated C, and MSVC interprets tokens like ``__is_trivial``,
41+
# ``__is_class``, ``__is_base_of``, ``__is_constructible``,
42+
# ``__has_trivial_destructor``, ``__has_virtual_destructor`` as built-in
43+
# compiler intrinsics — resulting in ``error C4233: nonstandard extension
44+
# used`` on Windows wheel builds. Prefer ``_trivial`` / ``_class`` /
45+
# ``_base_of`` / ``_constructible`` over the ``is_``/``has_`` prefix form.
3646
HOT_MODULES: tuple[str, ...] = (
3747
"src/hawkapi/routing/_radix_tree.py",
3848
"src/hawkapi/routing/route.py",

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "hawkapi"
7-
version = "0.1.4"
7+
version = "0.1.5"
88
description = "High-performance Python web framework — faster alternative to FastAPI"
99
readme = "README.md"
1010
license = { file = "LICENSE" }
@@ -100,13 +100,19 @@ markers = [
100100
[tool.ruff]
101101
target-version = "py312"
102102
line-length = 100
103+
# Ignore local venvs, build artefacts, and agent scratch worktrees —
104+
# none are part of the project source tree or tracked in git.
105+
extend-exclude = [".venv", "site", "build", "worktrees"]
103106

104107
[tool.ruff.lint]
105108
select = ["E", "F", "I", "UP", "B", "SIM", "S"]
106109
ignore = ["S101"]
107110

108111
[tool.ruff.lint.per-file-ignores]
109112
"tests/**" = ["S"]
113+
"benchmarks/**" = ["S", "F401", "F841", "E402", "F541", "E501", "I001", "B007", "SIM"]
114+
"examples/**" = ["F401", "F841", "E402", "I001", "S"]
115+
"hatch_build.py" = ["SIM105"]
110116

111117
[tool.coverage.run]
112118
source = ["hawkapi"]

src/hawkapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
from hawkapi.validation.constraints import Body, Cookie, Header, Path, Query
8080
from hawkapi.websocket import WebSocket, WebSocketDisconnect
8181

82-
__version__ = "0.1.4"
82+
__version__ = "0.1.5"
8383

8484
# Lazy imports — loaded on first access for faster cold start
8585
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {

src/hawkapi/app.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from hawkapi._types import ASGIApp, Receive, Scope, Send
1515
from hawkapi.background import BackgroundTasks
1616
from hawkapi.di.container import Container
17-
from hawkapi.di.resolver import resolve_dependencies, resolve_from_plan
17+
from hawkapi.di.param_plan import ParamSource
18+
from hawkapi.di.resolver import (
19+
_coerce_fast, # pyright: ignore[reportPrivateUsage]
20+
resolve_dependencies,
21+
resolve_from_plan,
22+
)
1823
from hawkapi.di.scope import Scope as DIScope
1924
from hawkapi.exceptions import HTTPException
2025
from hawkapi.lifespan.hooks import HookRegistry
@@ -573,11 +578,6 @@ async def _execute_trivial_route(
573578
# IMPLICIT_PATH/IMPLICIT_QUERY/PATH params, no DI, no body, no cleanup.
574579
# Coercion (e.g. int query params) can raise RequestValidationError,
575580
# so the entire kwargs-build + handler call is inside the try block.
576-
from hawkapi.di.param_plan import ParamSource # noqa: PLC0415
577-
from hawkapi.di.resolver import (
578-
_coerce_fast, # noqa: PLC0415 # pyright: ignore[reportPrivateUsage]
579-
)
580-
581581
try:
582582
kwargs: dict[str, Any] = {}
583583
if plan is not None:
@@ -586,11 +586,16 @@ async def _execute_trivial_route(
586586
if src is ParamSource.REQUEST:
587587
kwargs[spec.name] = request
588588
elif src is ParamSource.PATH or src is ParamSource.IMPLICIT_PATH:
589-
value = request.path_params.get(spec.alias or spec.name)
590-
if value is None and spec.has_marker_default:
589+
raw = request.path_params.get(spec.alias or spec.name)
590+
if raw is not None and spec.coerce_type is not None:
591+
# path_params values are always str — coerce to the
592+
# handler's declared type (int / uuid / float / …).
593+
kwargs[spec.name] = _coerce_fast(str(raw), spec.coerce_type)
594+
elif raw is None and spec.has_marker_default:
591595
mdf = spec.marker_default_factory
592-
value = mdf() if mdf is not None else spec.marker_default
593-
kwargs[spec.name] = value
596+
kwargs[spec.name] = mdf() if mdf is not None else spec.marker_default
597+
else:
598+
kwargs[spec.name] = raw
594599
elif src is ParamSource.QUERY or src is ParamSource.IMPLICIT_QUERY:
595600
qval = request.query_params.get(spec.alias or spec.name)
596601
if qval is not None:
@@ -633,10 +638,13 @@ async def _execute_trivial_route(
633638
response = await self._handle_exception(request, exc)
634639

635640
# Minimal HEAD handling: zero out the body but keep content-length.
636-
# StreamingResponse is not a Response subclass — fall back to the
637-
# general path if somehow one slips through (guards _trivial calc).
641+
# StreamingResponse-returning handlers are excluded at registration
642+
# time by ``_compute_trivial`` so they never reach this path — if one
643+
# somehow does (subclassing edge case), dispatch it as-is rather than
644+
# re-running the handler via the general path (which would double
645+
# any side effects).
638646
if isinstance(response, StreamingResponse):
639-
await self._execute_route(scope, receive, send, route, plan, request)
647+
await response(scope, receive, send)
640648
return
641649

642650
if scope["method"] == "HEAD" and hasattr(response, "body"):

src/hawkapi/cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ def main(argv: list[str] | None = None) -> None:
182182
action="store_true",
183183
help="Apply safe auto-fixes where available (v1: none implemented)",
184184
)
185+
doctor_parser.add_argument(
186+
"--offline",
187+
action="store_true",
188+
help="Skip rules that require network access (e.g. DOC050 PyPI version check)",
189+
)
185190

186191
# `hawkapi migrate` subcommand
187192
migrate_parser = subparsers.add_parser(
@@ -249,7 +254,14 @@ def _run_doctor(args: argparse.Namespace) -> int:
249254
if args.fix:
250255
print("--fix: no auto-fixable findings in v1.")
251256

252-
findings = run(app, min_severity=min_sev)
257+
rules: list[Any] | None = None
258+
if args.offline:
259+
from hawkapi.doctor.rules import ALL_RULES # noqa: PLC0415
260+
261+
# Drop rules tagged as requiring network access (e.g. DOC050 queries PyPI).
262+
rules = [r for r in ALL_RULES if not getattr(r, "requires_network", False)]
263+
264+
findings = run(app, rules=rules, min_severity=min_sev)
253265

254266
if args.output_format == "json":
255267
print(format_json(findings, args.app))

src/hawkapi/doctor/rules/deps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class _DOC050:
3131
severity: Severity = Severity.INFO
3232
title: str = "HawkAPI version older than latest published on PyPI"
3333
docs_url: str = docs_url("DOC050")
34+
# Flag consumed by ``hawkapi doctor --offline`` to skip rules that phone home.
35+
requires_network: bool = True
3436

3537
def check(self, app: HawkAPI) -> list[Finding]:
3638
import hawkapi

src/hawkapi/flags/providers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ def _ensure_loaded(self) -> None:
180180
f"Unsupported flag file extension {ext!r}. Use .json, .toml, .yaml, or .yml."
181181
)
182182

183+
# Order matters under concurrent reads (free-threaded CPython or
184+
# thread-pool workers): if we updated _mtime first, another thread
185+
# could see the new mtime, skip the reload, and read the stale
186+
# _cache. Assigning _cache first and _mtime last means readers that
187+
# observe the new mtime always observe the new cache too. Both
188+
# assignments are atomic at the Python level.
183189
self._cache = data if isinstance(data, dict) else {}
184190
self._mtime = current_mtime
185191

src/hawkapi/graphql/_graphiql.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
"""GraphiQL HTML template served for browser requests."""
1+
"""GraphiQL HTML template served for browser requests.
2+
3+
All CDN assets are pinned to exact versions and protected by Subresource
4+
Integrity (SRI) hashes so a compromised CDN cannot inject arbitrary JS into
5+
the developer's browser. Regenerate the hashes with::
6+
7+
curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
8+
"""
29

310
from __future__ import annotations
411

@@ -13,21 +20,26 @@
1320
#graphiql { height: 100vh; }
1421
</style>
1522
<link rel="stylesheet"
16-
href="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.css" />
23+
href="https://cdn.jsdelivr.net/npm/graphiql@3.0.9/graphiql.min.css"
24+
integrity="sha384-yz3/sqpuplkA7msMo0FE4ekg0xdwdvZ8JX9MVZREsxipqjU4h8IRfmAMRcb1QpUy"
25+
crossorigin="anonymous" />
1726
</head>
1827
<body>
1928
<div id="graphiql">Loading&hellip;</div>
2029
<script
21-
crossorigin
22-
src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"
30+
src="https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js"
31+
integrity="sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"
32+
crossorigin="anonymous"
2333
></script>
2434
<script
25-
crossorigin
26-
src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"
35+
src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js"
36+
integrity="sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"
37+
crossorigin="anonymous"
2738
></script>
2839
<script
29-
crossorigin
30-
src="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.js"
40+
src="https://cdn.jsdelivr.net/npm/graphiql@3.0.9/graphiql.min.js"
41+
integrity="sha384-Mjte+vxCWz1ZYCzszGHiJqJa5eAxiqI4mc3BErq7eDXnt+UGLXSEW7+i0wmfPiji"
42+
crossorigin="anonymous"
3143
></script>
3244
<script>
3345
const root = ReactDOM.createRoot(document.getElementById('graphiql'));

0 commit comments

Comments
 (0)