fenrir-framework 1.2.2__tar.gz → 2.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 (86) hide show
  1. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/PKG-INFO +36 -7
  2. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/README.md +33 -6
  3. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/__init__.py +30 -4
  4. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/app.py +45 -14
  5. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/cli.py +2 -2
  6. fenrir_framework-2.2.2/fenrir/middleware.py +399 -0
  7. fenrir_framework-2.2.2/fenrir/pagination.py +132 -0
  8. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/routing.py +16 -5
  9. fenrir_framework-2.2.2/fenrir/sessions.py +304 -0
  10. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/websocket.py +29 -3
  11. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir_framework.egg-info/PKG-INFO +36 -7
  12. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir_framework.egg-info/SOURCES.txt +3 -0
  13. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir_framework.egg-info/requires.txt +3 -0
  14. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/pyproject.toml +4 -1
  15. fenrir_framework-2.2.2/tests/test_new_middleware_features.py +978 -0
  16. fenrir_framework-1.2.2/fenrir/sessions.py +0 -93
  17. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/LICENSE +0 -0
  18. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/background.py +0 -0
  19. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/bottle.py +0 -0
  20. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/compat.py +0 -0
  21. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/config.py +0 -0
  22. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/context.py +0 -0
  23. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/dependencies.py +0 -0
  24. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/exceptions.py +0 -0
  25. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/falcon.py +0 -0
  26. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/helpers.py +0 -0
  27. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/json.py +0 -0
  28. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/logo.jpg +0 -0
  29. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/logo.png +0 -0
  30. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/openapi.py +0 -0
  31. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/request.py +0 -0
  32. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/response.py +0 -0
  33. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/sanic.py +0 -0
  34. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/security.py +0 -0
  35. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/signals.py +0 -0
  36. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/sse.py +0 -0
  37. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/templating.py +0 -0
  38. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/testing.py +0 -0
  39. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/upload.py +0 -0
  40. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir/views.py +0 -0
  41. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir_framework.egg-info/dependency_links.txt +0 -0
  42. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir_framework.egg-info/entry_points.txt +0 -0
  43. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/fenrir_framework.egg-info/top_level.txt +0 -0
  44. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/setup.cfg +0 -0
  45. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_appctx.py +0 -0
  46. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_async.py +0 -0
  47. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_basic.py +0 -0
  48. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_blueprints.py +0 -0
  49. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_circular_deps.py +0 -0
  50. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_cli.py +0 -0
  51. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_config.py +0 -0
  52. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_context.py +0 -0
  53. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_converters.py +0 -0
  54. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_custom_template.py +0 -0
  55. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_dep_overrides.py +0 -0
  56. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_dependencies.py +0 -0
  57. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_falcon_compat.py +0 -0
  58. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_form_file.py +0 -0
  59. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_helpers.py +0 -0
  60. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_instance_config.py +0 -0
  61. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_json.py +0 -0
  62. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_json_tag.py +0 -0
  63. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_logging.py +0 -0
  64. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_logo_route.py +0 -0
  65. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_middleware.py +0 -0
  66. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_new_features.py +0 -0
  67. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_regression.py +0 -0
  68. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_reqctx.py +0 -0
  69. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_request.py +0 -0
  70. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_resources.py +0 -0
  71. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_router_circular.py +0 -0
  72. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_routing.py +0 -0
  73. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_sanic_compat.py +0 -0
  74. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_security.py +0 -0
  75. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_session_interface.py +0 -0
  76. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_signals.py +0 -0
  77. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_sse.py +0 -0
  78. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_strict_content_type.py +0 -0
  79. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_subclassing.py +0 -0
  80. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_templating.py +0 -0
  81. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_testing.py +0 -0
  82. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_user_error_handler.py +0 -0
  83. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_validation.py +0 -0
  84. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_views.py +0 -0
  85. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_websocket.py +0 -0
  86. {fenrir_framework-1.2.2 → fenrir_framework-2.2.2}/tests/test_yield_deps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fenrir-framework
3
- Version: 1.2.2
3
+ Version: 2.2.2
4
4
  Summary: A hybrid Python web framework combining Flask, FastAPI, Bottle, Falcon, and Sanic
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -11,6 +11,8 @@ Requires-Dist: asteri>=2.2.2
11
11
  Requires-Dist: itsdangerous>=2.0.0
12
12
  Requires-Dist: python-multipart>=0.0.18
13
13
  Requires-Dist: typing_extensions>=4.0.0
14
+ Provides-Extra: redis
15
+ Requires-Dist: redis>=4.0.0; extra == "redis"
14
16
  Dynamic: license-file
15
17
 
16
18
  <p align="center">
@@ -22,7 +24,7 @@ Dynamic: license-file
22
24
  [![PyPI version](https://img.shields.io/pypi/v/fenrir-framework.svg?color=blueviolet)](https://pypi.org/project/fenrir-framework/)
23
25
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
24
26
  [![Python Version](https://img.shields.io/badge/Python-3.8%2B-blue.svg)](https://www.python.org/)
25
- [![Tests](https://img.shields.io/badge/Tests-482%20Passed-brightgreen.svg)](https://github.com/IshikawaUta/fenrir/actions)
27
+ [![Tests](https://img.shields.io/badge/Tests-528%20Passed-brightgreen.svg)](https://github.com/IshikawaUta/fenrir/actions)
26
28
  [![CI](https://github.com/IshikawaUta/fenrir/actions/workflows/test.yml/badge.svg)](https://github.com/IshikawaUta/fenrir/actions/workflows/test.yml)
27
29
  [![Performance](https://img.shields.io/badge/Performance-High--Speed%20ASGI-orange.svg)]()
28
30
 
@@ -83,7 +85,7 @@ logging.basicConfig(level=logging.INFO)
83
85
  logger = logging.getLogger("demo")
84
86
 
85
87
  # Initialize the Hybrid App
86
- app = Fenrir(title="Fenrir Hybrid Demo", version="1.2.2")
88
+ app = Fenrir(title="Fenrir Hybrid Demo", version="2.2.2")
87
89
 
88
90
  # --- 1. FastAPI-Style Validation & DI ---
89
91
  class UserRegister(BaseModel):
@@ -178,7 +180,7 @@ fenrir info demo_app:app
178
180
 
179
181
  ## 🧪 Comprehensive Test Suite
180
182
 
181
- Fenrir is thoroughly covered by an automated test suite comprising **482 tests** validating every single component, compat namespace, file upload, routing detail, and CLI functionality. The suite runs automatically via **GitHub Actions** on every push across Python **3.8 – 3.13**.
183
+ Fenrir is thoroughly covered by an automated test suite comprising **528 tests** validating every single component, compat namespace, file upload, routing detail, and CLI functionality. The suite runs automatically via **GitHub Actions** on every push across Python **3.8 – 3.13**.
182
184
 
183
185
  Run the test suite locally:
184
186
 
@@ -188,13 +190,40 @@ PYTHONPATH=. pytest -v
188
190
 
189
191
  ### Output:
190
192
  ```text
191
- ======================== 482 passed, 1 skipped in 3.4s ========================
193
+ ======================= 528 passed, 1 skipped in 3.4s ========================
192
194
  ```
193
195
 
194
196
  ---
195
197
 
196
198
  ## 🔄 Changelog
197
199
 
200
+ ### v2.2.2 — Major Feature Update
201
+
202
+ New middleware, session backends, pagination, and more:
203
+
204
+ **New Middleware (`fenrir.middleware`)**
205
+ - **CORSMiddleware**: Full CORS support for HTTP and WebSocket with configurable origins, methods, headers, credentials, and max-age.
206
+ - **GZipMiddleware**: Automatic gzip compression for responses above a configurable size threshold.
207
+ - **RequestIDMiddleware**: Auto-generates unique request IDs or forwards client-provided IDs via configurable header.
208
+ - **RateLimitMiddleware**: Sliding-window rate limiter per client IP with configurable limits and block status code.
209
+
210
+ **New Session Backends (`fenrir.sessions`)**
211
+ - **InMemorySessionInterface**: In-memory session storage with TTL expiration, suitable for single-process apps and testing.
212
+ - **RedisSessionInterface**: Redis-backed session storage with support for both sync (`fakeredis`) and async (`redis.asyncio`) clients. Install with `pip install fenrir-framework[redis]`.
213
+
214
+ **New Pagination Utilities (`fenrir.pagination`)**
215
+ - **PaginationParams**: Pydantic model for query parameters (`page`, `page_size`, `sort_by`, `sort_order`).
216
+ - **paginate()**: Utility to paginate SQLAlchemy-style query results with metadata.
217
+ - **paginate_dict()**: Utility to paginate lists of dictionaries.
218
+
219
+ **New Features**
220
+ - **WebSocket per-route timeout**: `@app.websocket("/ws", timeout=5.0)` raises `WebSocketTimeout` if no message received within the timeout.
221
+ - **Multiple response models per status**: `response_models={200: SuccessModel, 404: ErrorModel}` applies different models based on the actual response status code.
222
+
223
+ **Improvements**
224
+ - ASGI middleware stack is now built once and cached, with automatic invalidation when new middleware is added.
225
+ - Zero deprecation warnings across the entire test suite (528 tests).
226
+
198
227
  ### v1.2.2 — Logo & Favicon Patch
199
228
 
200
229
  High-quality logo assets and resolved CLI template favicon issues:
@@ -213,7 +242,7 @@ Logo and favicon assets are now properly included in the package distribution:
213
242
  - Moved `logo.png` and `logo.jpg` from repository root to `fenrir/` package directory.
214
243
  - Added `[tool.setuptools.package-data]` configuration in `pyproject.toml` to include image files: `fenrir = ["logo.png", "logo.jpg"]`.
215
244
  - Updated `fenrir/cli.py` `cmd_new()` function to look for logos in the fenrir package directory first, with fallbacks for development mode.
216
- - **Result**: All tests pass (482 unit tests + 13 advanced tests). `fenrir new` now works correctly in all environments.
245
+ - **Result**: All tests pass (528 unit tests). `fenrir new` now works correctly in all environments.
217
246
 
218
247
  ### v1.1.1 — Python 3.8–3.10 Full Compatibility Patch
219
248
 
@@ -245,7 +274,7 @@ Five test failures on Python 3.8 CI were identified and patched:
245
274
 
246
275
  ### v0.1.0 — Initial Release
247
276
  - Core ASGI framework with Flask, FastAPI, Sanic, Falcon, and Bottle hybridization.
248
- - 482 automated unit tests.
277
+ - 528 automated unit tests.
249
278
  - Premium CLI tooling (`run`, `routes`, `shell`, `bench`, `new`, `info`).
250
279
  - Auto-generated OpenAPI/Swagger documentation.
251
280
  - WebSocket and Server-Sent Events support.
@@ -7,7 +7,7 @@
7
7
  [![PyPI version](https://img.shields.io/pypi/v/fenrir-framework.svg?color=blueviolet)](https://pypi.org/project/fenrir-framework/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
9
  [![Python Version](https://img.shields.io/badge/Python-3.8%2B-blue.svg)](https://www.python.org/)
10
- [![Tests](https://img.shields.io/badge/Tests-482%20Passed-brightgreen.svg)](https://github.com/IshikawaUta/fenrir/actions)
10
+ [![Tests](https://img.shields.io/badge/Tests-528%20Passed-brightgreen.svg)](https://github.com/IshikawaUta/fenrir/actions)
11
11
  [![CI](https://github.com/IshikawaUta/fenrir/actions/workflows/test.yml/badge.svg)](https://github.com/IshikawaUta/fenrir/actions/workflows/test.yml)
12
12
  [![Performance](https://img.shields.io/badge/Performance-High--Speed%20ASGI-orange.svg)]()
13
13
 
@@ -68,7 +68,7 @@ logging.basicConfig(level=logging.INFO)
68
68
  logger = logging.getLogger("demo")
69
69
 
70
70
  # Initialize the Hybrid App
71
- app = Fenrir(title="Fenrir Hybrid Demo", version="1.2.2")
71
+ app = Fenrir(title="Fenrir Hybrid Demo", version="2.2.2")
72
72
 
73
73
  # --- 1. FastAPI-Style Validation & DI ---
74
74
  class UserRegister(BaseModel):
@@ -163,7 +163,7 @@ fenrir info demo_app:app
163
163
 
164
164
  ## 🧪 Comprehensive Test Suite
165
165
 
166
- Fenrir is thoroughly covered by an automated test suite comprising **482 tests** validating every single component, compat namespace, file upload, routing detail, and CLI functionality. The suite runs automatically via **GitHub Actions** on every push across Python **3.8 – 3.13**.
166
+ Fenrir is thoroughly covered by an automated test suite comprising **528 tests** validating every single component, compat namespace, file upload, routing detail, and CLI functionality. The suite runs automatically via **GitHub Actions** on every push across Python **3.8 – 3.13**.
167
167
 
168
168
  Run the test suite locally:
169
169
 
@@ -173,13 +173,40 @@ PYTHONPATH=. pytest -v
173
173
 
174
174
  ### Output:
175
175
  ```text
176
- ======================== 482 passed, 1 skipped in 3.4s ========================
176
+ ======================= 528 passed, 1 skipped in 3.4s ========================
177
177
  ```
178
178
 
179
179
  ---
180
180
 
181
181
  ## 🔄 Changelog
182
182
 
183
+ ### v2.2.2 — Major Feature Update
184
+
185
+ New middleware, session backends, pagination, and more:
186
+
187
+ **New Middleware (`fenrir.middleware`)**
188
+ - **CORSMiddleware**: Full CORS support for HTTP and WebSocket with configurable origins, methods, headers, credentials, and max-age.
189
+ - **GZipMiddleware**: Automatic gzip compression for responses above a configurable size threshold.
190
+ - **RequestIDMiddleware**: Auto-generates unique request IDs or forwards client-provided IDs via configurable header.
191
+ - **RateLimitMiddleware**: Sliding-window rate limiter per client IP with configurable limits and block status code.
192
+
193
+ **New Session Backends (`fenrir.sessions`)**
194
+ - **InMemorySessionInterface**: In-memory session storage with TTL expiration, suitable for single-process apps and testing.
195
+ - **RedisSessionInterface**: Redis-backed session storage with support for both sync (`fakeredis`) and async (`redis.asyncio`) clients. Install with `pip install fenrir-framework[redis]`.
196
+
197
+ **New Pagination Utilities (`fenrir.pagination`)**
198
+ - **PaginationParams**: Pydantic model for query parameters (`page`, `page_size`, `sort_by`, `sort_order`).
199
+ - **paginate()**: Utility to paginate SQLAlchemy-style query results with metadata.
200
+ - **paginate_dict()**: Utility to paginate lists of dictionaries.
201
+
202
+ **New Features**
203
+ - **WebSocket per-route timeout**: `@app.websocket("/ws", timeout=5.0)` raises `WebSocketTimeout` if no message received within the timeout.
204
+ - **Multiple response models per status**: `response_models={200: SuccessModel, 404: ErrorModel}` applies different models based on the actual response status code.
205
+
206
+ **Improvements**
207
+ - ASGI middleware stack is now built once and cached, with automatic invalidation when new middleware is added.
208
+ - Zero deprecation warnings across the entire test suite (528 tests).
209
+
183
210
  ### v1.2.2 — Logo & Favicon Patch
184
211
 
185
212
  High-quality logo assets and resolved CLI template favicon issues:
@@ -198,7 +225,7 @@ Logo and favicon assets are now properly included in the package distribution:
198
225
  - Moved `logo.png` and `logo.jpg` from repository root to `fenrir/` package directory.
199
226
  - Added `[tool.setuptools.package-data]` configuration in `pyproject.toml` to include image files: `fenrir = ["logo.png", "logo.jpg"]`.
200
227
  - Updated `fenrir/cli.py` `cmd_new()` function to look for logos in the fenrir package directory first, with fallbacks for development mode.
201
- - **Result**: All tests pass (482 unit tests + 13 advanced tests). `fenrir new` now works correctly in all environments.
228
+ - **Result**: All tests pass (528 unit tests). `fenrir new` now works correctly in all environments.
202
229
 
203
230
  ### v1.1.1 — Python 3.8–3.10 Full Compatibility Patch
204
231
 
@@ -230,7 +257,7 @@ Five test failures on Python 3.8 CI were identified and patched:
230
257
 
231
258
  ### v0.1.0 — Initial Release
232
259
  - Core ASGI framework with Flask, FastAPI, Sanic, Falcon, and Bottle hybridization.
233
- - 482 automated unit tests.
260
+ - 528 automated unit tests.
234
261
  - Premium CLI tooling (`run`, `routes`, `shell`, `bench`, `new`, `info`).
235
262
  - Auto-generated OpenAPI/Swagger documentation.
236
263
  - WebSocket and Server-Sent Events support.
@@ -2,7 +2,7 @@ from fenrir.app import Fenrir, Blueprint
2
2
  from fenrir.context import request, g, current_app, session
3
3
  from fenrir.dependencies import Depends, Query, Header, Cookie, Body, Path, Form, File
4
4
  from fenrir.upload import UploadFile
5
- from fenrir.websocket import WebSocket, WebSocketDisconnect
5
+ from fenrir.websocket import WebSocket, WebSocketDisconnect, WebSocketTimeout
6
6
  from fenrir.exceptions import (
7
7
  HTTPException,
8
8
  HTTPBadRequest,
@@ -47,14 +47,25 @@ import fenrir.bottle as bottle
47
47
  import fenrir.falcon as falcon
48
48
  import fenrir.sanic as sanic
49
49
  from fenrir.testing import TestClient, FenrirTestClient
50
-
51
-
50
+ from fenrir.middleware import (
51
+ CORSMiddleware,
52
+ GZipMiddleware,
53
+ RequestIDMiddleware,
54
+ RateLimitMiddleware,
55
+ )
56
+ from fenrir.pagination import PaginationParams, paginate, paginate_dict
57
+ from fenrir.sessions import (
58
+ RedisSessionInterface,
59
+ InMemorySessionInterface,
60
+ InMemorySessionBackend,
61
+ ServerSideSession,
62
+ )
52
63
 
53
64
 
54
65
  # Re-export Annotated for convenient use with param markers
55
66
  from fenrir.compat import Annotated
56
67
 
57
- __version__ = "1.2.2"
68
+ __version__ = "2.2.2"
58
69
  __all__ = [
59
70
  # Core app
60
71
  "Fenrir",
@@ -79,6 +90,7 @@ __all__ = [
79
90
  # WebSocket
80
91
  "WebSocket",
81
92
  "WebSocketDisconnect",
93
+ "WebSocketTimeout",
82
94
  # Exceptions
83
95
  "HTTPException",
84
96
  "HTTPBadRequest",
@@ -144,5 +156,19 @@ __all__ = [
144
156
  # Testing
145
157
  "TestClient",
146
158
  "FenrirTestClient",
159
+ # Middleware
160
+ "CORSMiddleware",
161
+ "GZipMiddleware",
162
+ "RequestIDMiddleware",
163
+ "RateLimitMiddleware",
164
+ # Pagination
165
+ "PaginationParams",
166
+ "paginate",
167
+ "paginate_dict",
168
+ # Server-side sessions
169
+ "RedisSessionInterface",
170
+ "InMemorySessionInterface",
171
+ "InMemorySessionBackend",
172
+ "ServerSideSession",
147
173
  ]
148
174
 
@@ -92,7 +92,7 @@ class Fenrir:
92
92
  self,
93
93
  import_name: str = None,
94
94
  title: str = "Fenrir API",
95
- version: str = "1.2.2",
95
+ version: str = "2.2.2",
96
96
  template_folder: str = "templates",
97
97
  renderer: Any = None,
98
98
  docs_url: str = "/docs",
@@ -168,6 +168,7 @@ class Fenrir:
168
168
  self.router = Router(route_class=route_class)
169
169
  self.middlewares: Dict[str, List[Callable]] = {"request": [], "response": []}
170
170
  self._asgi_middlewares: List = [] # ASGI-style middleware stack
171
+ self._asgi_app: Any = None # Built middleware app (cached)
171
172
  self._wsgi_mounts: List = [] # (prefix, WsgiToAsgi) pairs
172
173
  self.listeners: Dict[str, List[Callable]] = {
173
174
  "before_server_start": [],
@@ -204,9 +205,9 @@ class Fenrir:
204
205
  return handler
205
206
  return decorator
206
207
 
207
- def websocket(self, path: str):
208
+ def websocket(self, path: str, timeout: float = None):
208
209
  def decorator(handler):
209
- self.add_websocket_route(path, handler)
210
+ self.add_websocket_route(path, handler, ws_timeout=timeout)
210
211
  return handler
211
212
  return decorator
212
213
 
@@ -228,8 +229,8 @@ class Fenrir:
228
229
  def add_route(self, path: str, handler: Any, methods: List[str] = None, **route_kwargs):
229
230
  self.router.add_route(path, handler, methods, **route_kwargs)
230
231
 
231
- def add_websocket_route(self, path: str, handler: Any):
232
- self.router.add_websocket_route(path, handler)
232
+ def add_websocket_route(self, path: str, handler: Any, ws_timeout: float = None):
233
+ self.router.add_websocket_route(path, handler, ws_timeout=ws_timeout)
233
234
 
234
235
  def include_router(self, router: Router, prefix: str = ""):
235
236
  self.router.include_router(router, prefix=prefix)
@@ -241,6 +242,7 @@ class Fenrir:
241
242
  as ``await mw(scope, receive, send)``.
242
243
  """
243
244
  self._asgi_middlewares.append((middleware_class, options))
245
+ self._asgi_app = None # invalidate cached middleware stack
244
246
 
245
247
  def mount_wsgi(self, path: str, wsgi_app: Any) -> None:
246
248
  """Mount a WSGI application (e.g. a Bottle app) under *path*.
@@ -398,12 +400,15 @@ class Fenrir:
398
400
  global _active_app
399
401
  _active_app = self
400
402
 
401
- # Apply ASGI middleware stack (outermost first, added last)
402
- if self._asgi_middlewares:
403
+ # Build ASGI middleware stack once and cache it
404
+ if self._asgi_middlewares and not self._asgi_app:
403
405
  app = self._dispatch
404
406
  for mw_class, mw_options in reversed(self._asgi_middlewares):
405
407
  app = mw_class(app, **mw_options)
406
- await app(scope, receive, send)
408
+ self._asgi_app = app
409
+
410
+ if self._asgi_app:
411
+ await self._asgi_app(scope, receive, send)
407
412
  return
408
413
 
409
414
  await self._dispatch(scope, receive, send)
@@ -450,7 +455,8 @@ class Fenrir:
450
455
  await send({"type": "websocket.close", "code": 1008})
451
456
  return
452
457
 
453
- ws = WebSocket(scope, receive, send)
458
+ ws_timeout = getattr(route, "ws_timeout", None)
459
+ ws = WebSocket(scope, receive, send, timeout=ws_timeout)
454
460
  resp = Response(status=200)
455
461
  token_req = _request_ctx_var.set(req)
456
462
  token_g = _g_ctx_var.set(G())
@@ -569,6 +575,9 @@ class Fenrir:
569
575
  elif hasattr(route, "response_model") and route.response_model is not None:
570
576
  coerced = self._apply_response_model(route, response_obj)
571
577
  response_obj = coerced
578
+ elif hasattr(route, "response_models") and route.response_models:
579
+ coerced = self._apply_response_model(route, response_obj)
580
+ response_obj = coerced
572
581
 
573
582
  # Convert handler result to Response object (only if not already a Response)
574
583
  if not isinstance(response_obj, Response):
@@ -678,11 +687,37 @@ class Fenrir:
678
687
  return await to_thread(func, *args, **kwargs)
679
688
 
680
689
  def _apply_response_model(self, route: Route, content: Any) -> Response:
681
- """Serialise *content* through the route's response_model if defined."""
690
+ """Serialise *content* through the route's response_model if defined.
691
+
692
+ Also supports ``response_models`` dict: ``{status_code: model}`` for
693
+ multiple response models per status code (runtime validation).
694
+ """
695
+ # Determine actual status code from content tuple
696
+ actual_status = getattr(route, "status_code", 200)
697
+ actual_content = content
698
+ if isinstance(content, tuple):
699
+ if len(content) == 2:
700
+ actual_content, actual_status = content
701
+ elif len(content) == 3:
702
+ actual_content, actual_status, _ = content
703
+
704
+ # Check response_models dict first (multiple models per status)
705
+ response_models = getattr(route, "response_models", {})
706
+ if response_models and actual_status in response_models:
707
+ rm = response_models[actual_status]
708
+ return self._serialize_with_model(rm, actual_content, actual_status, route)
709
+
710
+ # Fall back to the single response_model
682
711
  rm = getattr(route, "response_model", None)
683
712
  if rm is None:
713
+ if isinstance(content, tuple):
714
+ return self._coerce_response(content)
684
715
  return self._coerce_response(content)
685
716
 
717
+ return self._serialize_with_model(rm, actual_content, actual_status, route)
718
+
719
+ def _serialize_with_model(self, rm: Any, content: Any, status: int, route: Route) -> Response:
720
+ """Serialize *content* through a Pydantic model."""
686
721
  try:
687
722
  from pydantic import BaseModel, TypeAdapter
688
723
 
@@ -690,13 +725,9 @@ class Fenrir:
690
725
  exclude_defaults = getattr(route, "response_model_exclude_defaults", False)
691
726
  include = getattr(route, "response_model_include", None)
692
727
  exclude = getattr(route, "response_model_exclude", None)
693
- status = getattr(route, "status_code", 200)
694
728
 
695
729
  if isinstance(rm, type) and issubclass(rm, BaseModel):
696
- # Always go through response_model.model_validate so extra fields
697
- # from a different Pydantic model are properly stripped.
698
730
  if isinstance(content, BaseModel):
699
- # Convert via dict to let response_model pick only its fields
700
731
  raw_dict = content.model_dump()
701
732
  validated = rm.model_validate(raw_dict)
702
733
  elif isinstance(content, dict):
@@ -375,7 +375,7 @@ def cmd_new(args):
375
375
  import os
376
376
  import sys
377
377
 
378
- app = Fenrir(title="My Fenrir Application", version="1.2.2")
378
+ app = Fenrir(title="My Fenrir Application", version="2.2.2")
379
379
 
380
380
  @app.get("/")
381
381
  async def home():
@@ -682,7 +682,7 @@ if __name__ == "__main__":
682
682
  </div>
683
683
  <div class="info-item">
684
684
  <span class="info-label">Framework Engine</span>
685
- <span class="info-value">Fenrir v1.2.2</span>
685
+ <span class="info-value">Fenrir v2.2.2</span>
686
686
  </div>
687
687
  </div>
688
688