fusion-framework 1.2.0__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/Cargo.lock +4 -4
  2. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/Cargo.toml +1 -1
  3. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/PKG-INFO +1 -1
  4. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/pyproject.toml +1 -1
  5. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/middleware.py +38 -13
  6. fusion_framework-1.2.2/python/fusion_framework/test_middleware.py +87 -0
  7. fusion_framework-1.2.0/python/fusion_framework/test_middleware.py +0 -30
  8. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/README.md +0 -0
  9. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/Cargo.toml +0 -0
  10. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/api_context.rs +0 -0
  11. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/coerce.rs +0 -0
  12. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/dispatch.rs +0 -0
  13. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/error.rs +0 -0
  14. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/handler.rs +0 -0
  15. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/headers.rs +0 -0
  16. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/http_error.rs +0 -0
  17. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/lib.rs +0 -0
  18. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/main.rs +0 -0
  19. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/naming.rs +0 -0
  20. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/request.rs +0 -0
  21. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/response.rs +0 -0
  22. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/router.rs +0 -0
  23. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/serialize.rs +0 -0
  24. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/server.rs +0 -0
  25. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/settings.rs +0 -0
  26. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-core/src/status.rs +0 -0
  27. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-py/Cargo.toml +0 -0
  28. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-py/README.md +0 -0
  29. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-py/src/api_types.rs +0 -0
  30. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-py/src/json.rs +0 -0
  31. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/crates/fusion-py/src/lib.rs +0 -0
  32. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/__init__.py +0 -0
  33. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/api.py +0 -0
  34. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/app.py +0 -0
  35. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/async_runtime.py +0 -0
  36. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/config.py +0 -0
  37. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/header.py +0 -0
  38. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/http.py +0 -0
  39. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/route.py +0 -0
  40. {fusion_framework-1.2.0 → fusion_framework-1.2.2}/python/fusion_framework/status.py +0 -0
@@ -124,7 +124,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
124
124
 
125
125
  [[package]]
126
126
  name = "fusion-core"
127
- version = "1.2.0"
127
+ version = "1.2.2"
128
128
  dependencies = [
129
129
  "bytes",
130
130
  "console",
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "fusion-ffi"
141
- version = "1.2.0"
141
+ version = "1.2.2"
142
142
  dependencies = [
143
143
  "bytes",
144
144
  "fusion-core",
@@ -148,7 +148,7 @@ dependencies = [
148
148
 
149
149
  [[package]]
150
150
  name = "fusion-node"
151
- version = "1.2.0"
151
+ version = "1.2.2"
152
152
  dependencies = [
153
153
  "fusion-core",
154
154
  "napi",
@@ -161,7 +161,7 @@ dependencies = [
161
161
 
162
162
  [[package]]
163
163
  name = "fusion-py"
164
- version = "1.2.0"
164
+ version = "1.2.2"
165
165
  dependencies = [
166
166
  "bytes",
167
167
  "fusion-core",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/fusion-core", "crates/fusion-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "1.2.0"
6
+ version = "1.2.2"
7
7
  edition = "2024"
8
8
 
9
9
  [workspace.dependencies]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusion-framework
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "fusion-framework"
7
- version = "1.2.0"
7
+ version = "1.2.2"
8
8
  description = "Class-based HTTP framework for Python, powered by a shared Rust core"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
5
  import base64
7
6
  import inspect
8
7
  import json
@@ -35,7 +34,7 @@ def clear_active_global() -> None:
35
34
 
36
35
 
37
36
  async def _maybe_await(value: MaybeAwaitable) -> Any:
38
- if asyncio.iscoroutine(value) or asyncio.isfuture(value):
37
+ if inspect.isawaitable(value):
39
38
  return await value
40
39
  return value
41
40
 
@@ -78,8 +77,11 @@ def _run_chain_sync(
78
77
  return dispatch(index + 1, next_req)
79
78
 
80
79
  result = middleware(req, call_next)
80
+ # Sync middleware may return a coroutine when the route handler is
81
+ # ``async def`` (HandlerInvoker is sync but yields an awaitable).
82
+ # Propagate to Rust so ``async_runtime.submit`` can await it.
81
83
  if inspect.isawaitable(result):
82
- raise RuntimeError("async middleware requires an async app runtime")
84
+ return result
83
85
  if _is_response(result):
84
86
  return result
85
87
  return result
@@ -203,6 +205,22 @@ def _merge_response_headers(result: Any, extra: dict[str, str]) -> Any:
203
205
  return out
204
206
 
205
207
 
208
+ def _call_next_merge_headers(
209
+ call_next: Callable[[RequestDict], Any],
210
+ request: RequestDict,
211
+ extra: dict[str, str],
212
+ ) -> Any:
213
+ """Merge headers after ``call_next``, awaiting async handler results."""
214
+ result = call_next(request)
215
+ if inspect.isawaitable(result):
216
+
217
+ async def _await_merge() -> Any:
218
+ return _merge_response_headers(await result, extra)
219
+
220
+ return _await_merge()
221
+ return _merge_response_headers(result, extra)
222
+
223
+
206
224
  def framework_headers() -> Middleware:
207
225
  """Default identity middleware: advertise Fusion on every response.
208
226
 
@@ -215,18 +233,25 @@ def framework_headers() -> Middleware:
215
233
 
216
234
  extra = dict(_fp())
217
235
  except Exception:
218
- from fusion_framework import header as hdr
219
-
220
- extra = {
221
- getattr(hdr, "X_POWERED_BY", "X-Powered-By"): getattr(
222
- hdr, "FRAMEWORK_POWERED_BY", "Fusion Framework"
223
- ),
224
- getattr(hdr, "X_FRAMEWORK", "X-Framework"): getattr(hdr, "FRAMEWORK_ID", "Fusion"),
225
- getattr(hdr, "X_FUSION_VERSION", "X-Fusion-Version"): "1.2.0",
226
- }
236
+ try:
237
+ from fusion_framework import header as hdr
238
+
239
+ extra = {
240
+ getattr(hdr, "X_POWERED_BY", "X-Powered-By"): getattr(
241
+ hdr, "FRAMEWORK_POWERED_BY", "Fusion Framework"
242
+ ),
243
+ getattr(hdr, "X_FRAMEWORK", "X-Framework"): getattr(hdr, "FRAMEWORK_ID", "Fusion"),
244
+ getattr(hdr, "X_FUSION_VERSION", "X-Fusion-Version"): "1.2.2",
245
+ }
246
+ except Exception:
247
+ extra = {
248
+ "X-Powered-By": "Fusion Framework",
249
+ "X-Framework": "Fusion",
250
+ "X-Fusion-Version": "1.2.2",
251
+ }
227
252
 
228
253
  def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any:
229
- return _merge_response_headers(call_next(request), extra)
254
+ return _call_next_merge_headers(call_next, request, extra)
230
255
 
231
256
  return middleware
232
257
 
@@ -0,0 +1,87 @@
1
+ """Unit tests for middleware chain (no server required)."""
2
+
3
+ import asyncio
4
+ import inspect
5
+
6
+ from fusion_framework.middleware import (
7
+ bearer_jwt,
8
+ clear_active_global,
9
+ dispatch_route,
10
+ framework_headers,
11
+ require_roles,
12
+ set_active_global,
13
+ )
14
+
15
+
16
+ def _handler(request):
17
+ return {"status": 200, "body": {"state": request.get("state", {})}}
18
+
19
+
20
+ async def _async_handler(request):
21
+ return {"status": 200, "body": {"ok": True, "path": request.get("path")}}
22
+
23
+
24
+ def _sync_invoker_like(request):
25
+ """Mirrors PyO3 HandlerInvoker: sync ``__call__`` that may return a coroutine."""
26
+ return _async_handler(request)
27
+
28
+
29
+ def test_require_roles_allows_matching_role():
30
+ request = {"headers": {}, "state": {"jwt": {"roles": ["admin"]}}}
31
+ chain = [require_roles("admin", "super_admin")]
32
+ result = dispatch_route(request, _handler, chain)
33
+ assert result["status"] == 200
34
+
35
+
36
+ def test_require_roles_blocks_missing_role():
37
+ request = {"headers": {}, "state": {"jwt": {"roles": ["user"]}}}
38
+ chain = [require_roles("admin")]
39
+ result = dispatch_route(request, _handler, chain)
40
+ assert result["status"] == 403
41
+
42
+
43
+ def test_bearer_jwt_populates_state():
44
+ token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0."
45
+ request = {"headers": {"Authorization": f"Bearer {token}"}}
46
+ set_active_global([bearer_jwt()])
47
+ try:
48
+ result = dispatch_route(request, _handler, [])
49
+ assert result["status"] == 200
50
+ assert result["body"]["state"]["jwt"]["sub"] == "1"
51
+ finally:
52
+ clear_active_global()
53
+
54
+
55
+ def test_framework_headers_awaits_async_handler():
56
+ """Regression: sync framework_headers must not stringify coroutine bodies.
57
+
58
+ HandlerInvoker is sync, so the chain stays sync; call_next returns a raw
59
+ coroutine that must be awaited before headers are merged.
60
+ """
61
+ clear_active_global()
62
+ set_active_global([framework_headers()])
63
+ try:
64
+ request = {"path": "/membership", "headers": {}, "method": "GET"}
65
+ result = dispatch_route(request, _sync_invoker_like, [])
66
+ assert inspect.isawaitable(result), "async handler result must stay awaitable for Rust"
67
+ resolved = asyncio.run(result)
68
+ assert resolved["status"] == 200
69
+ assert resolved["body"] == {"ok": True, "path": "/membership"}
70
+ headers = {str(k).lower(): v for k, v in (resolved.get("headers") or {}).items()}
71
+ assert "x-powered-by" in headers
72
+ body = resolved.get("body")
73
+ assert not (isinstance(body, str) and body.startswith("<coroutine"))
74
+ finally:
75
+ clear_active_global()
76
+
77
+
78
+ def test_sync_middleware_propagates_async_handler_coroutine():
79
+ clear_active_global()
80
+ try:
81
+ request = {"path": "/x", "headers": {}, "state": {"jwt": {"roles": ["admin"]}}}
82
+ result = dispatch_route(request, _sync_invoker_like, [require_roles("admin")])
83
+ assert inspect.isawaitable(result)
84
+ resolved = asyncio.run(result)
85
+ assert resolved["body"]["ok"] is True
86
+ finally:
87
+ clear_active_global()
@@ -1,30 +0,0 @@
1
- """Unit tests for middleware chain (no server required)."""
2
-
3
- from fusion_framework.middleware import bearer_jwt, dispatch_route, require_roles, set_active_global
4
-
5
-
6
- def _handler(request):
7
- return {"status": 200, "body": {"state": request.get("state", {})}}
8
-
9
-
10
- def test_require_roles_allows_matching_role():
11
- request = {"headers": {}, "state": {"jwt": {"roles": ["admin"]}}}
12
- chain = [require_roles("admin", "super_admin")]
13
- result = dispatch_route(request, _handler, chain)
14
- assert result["status"] == 200
15
-
16
-
17
- def test_require_roles_blocks_missing_role():
18
- request = {"headers": {}, "state": {"jwt": {"roles": ["user"]}}}
19
- chain = [require_roles("admin")]
20
- result = dispatch_route(request, _handler, chain)
21
- assert result["status"] == 403
22
-
23
-
24
- def test_bearer_jwt_populates_state():
25
- token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0."
26
- request = {"headers": {"Authorization": f"Bearer {token}"}}
27
- set_active_global([bearer_jwt()])
28
- result = dispatch_route(request, _handler, [])
29
- assert result["status"] == 200
30
- assert result["body"]["state"]["jwt"]["sub"] == "1"