nextpy-framework 1.0.0__py3-none-any.whl
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.
- nextpy/__init__.py +50 -0
- nextpy/auth.py +94 -0
- nextpy/builder.py +123 -0
- nextpy/cli.py +490 -0
- nextpy/components/__init__.py +45 -0
- nextpy/components/feedback.py +210 -0
- nextpy/components/form.py +346 -0
- nextpy/components/head.py +167 -0
- nextpy/components/hooks_provider.py +64 -0
- nextpy/components/image.py +180 -0
- nextpy/components/layout.py +206 -0
- nextpy/components/link.py +132 -0
- nextpy/components/loader.py +65 -0
- nextpy/components/toast.py +101 -0
- nextpy/components/visual.py +185 -0
- nextpy/config.py +75 -0
- nextpy/core/__init__.py +21 -0
- nextpy/core/builder.py +237 -0
- nextpy/core/data_fetching.py +221 -0
- nextpy/core/renderer.py +252 -0
- nextpy/core/router.py +233 -0
- nextpy/core/sync.py +34 -0
- nextpy/db.py +121 -0
- nextpy/dev_server.py +69 -0
- nextpy/dev_tools.py +157 -0
- nextpy/errors.py +70 -0
- nextpy/hooks.py +348 -0
- nextpy/performance.py +78 -0
- nextpy/plugins.py +61 -0
- nextpy/py.typed +0 -0
- nextpy/server/__init__.py +6 -0
- nextpy/server/app.py +325 -0
- nextpy/server/debug.py +93 -0
- nextpy/server/middleware.py +88 -0
- nextpy/utils/__init__.py +0 -0
- nextpy/utils/cache.py +89 -0
- nextpy/utils/email.py +59 -0
- nextpy/utils/file_upload.py +65 -0
- nextpy/utils/logging.py +52 -0
- nextpy/utils/search.py +59 -0
- nextpy/utils/seo.py +85 -0
- nextpy/utils/validators.py +58 -0
- nextpy/websocket.py +76 -0
- nextpy_framework-1.0.0.dist-info/METADATA +343 -0
- nextpy_framework-1.0.0.dist-info/RECORD +49 -0
- nextpy_framework-1.0.0.dist-info/WHEEL +5 -0
- nextpy_framework-1.0.0.dist-info/entry_points.txt +2 -0
- nextpy_framework-1.0.0.dist-info/licenses/LICENSE +21 -0
- nextpy_framework-1.0.0.dist-info/top_level.txt +1 -0
nextpy/server/app.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Server Application - FastAPI-based server
|
|
3
|
+
Handles routing, SSR, API routes, and static file serving
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import asyncio
|
|
9
|
+
import importlib.util
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request, Response, HTTPException
|
|
14
|
+
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
17
|
+
|
|
18
|
+
from nextpy.core.router import Router
|
|
19
|
+
from nextpy.core.renderer import Renderer
|
|
20
|
+
from nextpy.core.data_fetching import (
|
|
21
|
+
PageContext,
|
|
22
|
+
execute_data_fetching,
|
|
23
|
+
PageNotFoundError,
|
|
24
|
+
RedirectError,
|
|
25
|
+
)
|
|
26
|
+
from nextpy.server.middleware import NextPyMiddleware
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NextPyApp:
|
|
30
|
+
"""
|
|
31
|
+
Main NextPy application class
|
|
32
|
+
Wraps FastAPI and provides Next.js-like functionality
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
pages_dir: str = "pages",
|
|
38
|
+
templates_dir: str = "templates",
|
|
39
|
+
public_dir: str = "public",
|
|
40
|
+
out_dir: str = "out",
|
|
41
|
+
debug: bool = False,
|
|
42
|
+
):
|
|
43
|
+
self.pages_dir = Path(pages_dir)
|
|
44
|
+
self.templates_dir = Path(templates_dir)
|
|
45
|
+
self.public_dir = Path(public_dir)
|
|
46
|
+
self.out_dir = Path(out_dir)
|
|
47
|
+
self.debug = debug
|
|
48
|
+
self._modules_cache: Dict[str, Any] = {}
|
|
49
|
+
|
|
50
|
+
self.router = Router(str(self.pages_dir), str(self.templates_dir))
|
|
51
|
+
self.renderer = Renderer(
|
|
52
|
+
str(self.templates_dir),
|
|
53
|
+
str(self.pages_dir),
|
|
54
|
+
str(self.public_dir),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
self.app = FastAPI(
|
|
58
|
+
title="NextPy Application",
|
|
59
|
+
debug=debug,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self._setup_middleware()
|
|
63
|
+
self._setup_static_files()
|
|
64
|
+
self._setup_routes()
|
|
65
|
+
|
|
66
|
+
def _setup_middleware(self) -> None:
|
|
67
|
+
"""Configure middleware"""
|
|
68
|
+
self.app.add_middleware(
|
|
69
|
+
CORSMiddleware,
|
|
70
|
+
allow_origins=["*"],
|
|
71
|
+
allow_credentials=True,
|
|
72
|
+
allow_methods=["*"],
|
|
73
|
+
allow_headers=["*"],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self.app.add_middleware(NextPyMiddleware)
|
|
77
|
+
|
|
78
|
+
def _setup_static_files(self) -> None:
|
|
79
|
+
"""Mount static file directories"""
|
|
80
|
+
if self.public_dir.exists():
|
|
81
|
+
self.app.mount(
|
|
82
|
+
"/static",
|
|
83
|
+
StaticFiles(directory=str(self.public_dir)),
|
|
84
|
+
name="static",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
nextpy_static = self.out_dir / "_nextpy" / "static"
|
|
88
|
+
if nextpy_static.exists():
|
|
89
|
+
self.app.mount(
|
|
90
|
+
"/_nextpy/static",
|
|
91
|
+
StaticFiles(directory=str(nextpy_static)),
|
|
92
|
+
name="nextpy_static",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _setup_routes(self) -> None:
|
|
96
|
+
"""Set up the catch-all route handler"""
|
|
97
|
+
self.router.scan_pages()
|
|
98
|
+
|
|
99
|
+
@self.app.get("/")
|
|
100
|
+
async def index(request: Request) -> Response:
|
|
101
|
+
return await self._handle_request(request, "/")
|
|
102
|
+
|
|
103
|
+
@self.app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
104
|
+
async def catch_all(request: Request, path: str = "") -> Response:
|
|
105
|
+
return await self._handle_request(request, f"/{path}")
|
|
106
|
+
|
|
107
|
+
def _load_module_from_file(self, file_path: Path) -> Optional[Any]:
|
|
108
|
+
"""Load a Python module from a file path"""
|
|
109
|
+
try:
|
|
110
|
+
module_name = f"nextpy_page_{file_path.stem}_{hash(str(file_path))}"
|
|
111
|
+
|
|
112
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
113
|
+
if spec and spec.loader:
|
|
114
|
+
module = importlib.util.module_from_spec(spec)
|
|
115
|
+
sys.modules[module_name] = module
|
|
116
|
+
spec.loader.exec_module(module)
|
|
117
|
+
return module
|
|
118
|
+
except Exception as e:
|
|
119
|
+
if self.debug:
|
|
120
|
+
print(f"Error loading module from {file_path}: {e}")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
async def _handle_request(self, request: Request, path: str) -> Response:
|
|
124
|
+
"""Handle a page request"""
|
|
125
|
+
match = self.router.match(path)
|
|
126
|
+
|
|
127
|
+
if not match:
|
|
128
|
+
return await self._render_404(request)
|
|
129
|
+
|
|
130
|
+
route, params = match
|
|
131
|
+
|
|
132
|
+
if route.is_api:
|
|
133
|
+
return await self._handle_api_request(request, route, params)
|
|
134
|
+
|
|
135
|
+
context = PageContext(
|
|
136
|
+
params=params,
|
|
137
|
+
query=dict(request.query_params),
|
|
138
|
+
req=request,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
props = {}
|
|
143
|
+
module = self._load_module_from_file(route.file_path)
|
|
144
|
+
|
|
145
|
+
if module:
|
|
146
|
+
props = await execute_data_fetching(module, context)
|
|
147
|
+
|
|
148
|
+
template_name = self._get_template_name(route, module)
|
|
149
|
+
|
|
150
|
+
html = await self.renderer.render_async(
|
|
151
|
+
template_name,
|
|
152
|
+
context={
|
|
153
|
+
**props,
|
|
154
|
+
"params": params,
|
|
155
|
+
"query": dict(request.query_params),
|
|
156
|
+
"request": request,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return HTMLResponse(
|
|
161
|
+
content=html,
|
|
162
|
+
headers={
|
|
163
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
164
|
+
"Pragma": "no-cache",
|
|
165
|
+
"Expires": "0",
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
except PageNotFoundError:
|
|
170
|
+
return await self._render_404(request)
|
|
171
|
+
|
|
172
|
+
except RedirectError as e:
|
|
173
|
+
status_code = 308 if e.permanent else 307
|
|
174
|
+
return RedirectResponse(url=e.destination, status_code=status_code)
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
if self.debug:
|
|
178
|
+
import traceback
|
|
179
|
+
traceback.print_exc()
|
|
180
|
+
return await self._render_error(request, e)
|
|
181
|
+
|
|
182
|
+
async def _handle_api_request(
|
|
183
|
+
self,
|
|
184
|
+
request: Request,
|
|
185
|
+
route,
|
|
186
|
+
params: Optional[Dict[str, str]] = None
|
|
187
|
+
) -> Response:
|
|
188
|
+
"""Handle an API route request"""
|
|
189
|
+
params = params or {}
|
|
190
|
+
|
|
191
|
+
module = self._load_module_from_file(route.file_path)
|
|
192
|
+
if not module:
|
|
193
|
+
return JSONResponse(
|
|
194
|
+
{"error": "Module not found"},
|
|
195
|
+
status_code=500,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
method = request.method.lower()
|
|
199
|
+
handler = getattr(module, method, None) or getattr(module, method.upper(), None)
|
|
200
|
+
|
|
201
|
+
if not handler:
|
|
202
|
+
handler = getattr(module, "handler", None) or getattr(module, "default", None)
|
|
203
|
+
|
|
204
|
+
if not handler:
|
|
205
|
+
return JSONResponse(
|
|
206
|
+
{"error": f"No handler for {request.method}"},
|
|
207
|
+
status_code=405,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
if asyncio.iscoroutinefunction(handler):
|
|
212
|
+
result = await handler(request, params)
|
|
213
|
+
else:
|
|
214
|
+
result = handler(request, params)
|
|
215
|
+
|
|
216
|
+
if isinstance(result, Response):
|
|
217
|
+
return result
|
|
218
|
+
elif isinstance(result, dict):
|
|
219
|
+
return JSONResponse(result)
|
|
220
|
+
else:
|
|
221
|
+
return JSONResponse({"data": result})
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
if self.debug:
|
|
225
|
+
import traceback
|
|
226
|
+
traceback.print_exc()
|
|
227
|
+
return JSONResponse(
|
|
228
|
+
{"error": str(e)},
|
|
229
|
+
status_code=500,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _get_template_name(self, route, module: Optional[Any] = None) -> str:
|
|
233
|
+
"""Get the template name for a route"""
|
|
234
|
+
if module and hasattr(module, "get_template"):
|
|
235
|
+
return module.get_template()
|
|
236
|
+
|
|
237
|
+
relative = route.file_path.relative_to(self.pages_dir)
|
|
238
|
+
template_name = str(relative).replace(".py", ".html")
|
|
239
|
+
|
|
240
|
+
template_path = self.templates_dir / template_name
|
|
241
|
+
if template_path.exists():
|
|
242
|
+
return template_name
|
|
243
|
+
|
|
244
|
+
if route.file_path.stem == "index":
|
|
245
|
+
parent_template = self.templates_dir / route.file_path.parent.name / "index.html"
|
|
246
|
+
if parent_template.exists():
|
|
247
|
+
return str(parent_template.relative_to(self.templates_dir))
|
|
248
|
+
|
|
249
|
+
return "_page.html"
|
|
250
|
+
|
|
251
|
+
async def _render_404(self, request: Request) -> HTMLResponse:
|
|
252
|
+
"""Render the 404 page"""
|
|
253
|
+
try:
|
|
254
|
+
html = await self.renderer.render_async(
|
|
255
|
+
"_404.html",
|
|
256
|
+
context={"request": request},
|
|
257
|
+
)
|
|
258
|
+
except Exception:
|
|
259
|
+
html = """
|
|
260
|
+
<!DOCTYPE html>
|
|
261
|
+
<html>
|
|
262
|
+
<head><title>404 - Not Found</title></head>
|
|
263
|
+
<body>
|
|
264
|
+
<h1>404 - Page Not Found</h1>
|
|
265
|
+
<p>The page you're looking for doesn't exist.</p>
|
|
266
|
+
</body>
|
|
267
|
+
</html>
|
|
268
|
+
"""
|
|
269
|
+
return HTMLResponse(content=html, status_code=404)
|
|
270
|
+
|
|
271
|
+
async def _render_error(self, request: Request, error: Exception) -> HTMLResponse:
|
|
272
|
+
"""Render the error page"""
|
|
273
|
+
try:
|
|
274
|
+
html = await self.renderer.render_async(
|
|
275
|
+
"_error.html",
|
|
276
|
+
context={"request": request, "error": str(error)},
|
|
277
|
+
)
|
|
278
|
+
except Exception:
|
|
279
|
+
html = f"""
|
|
280
|
+
<!DOCTYPE html>
|
|
281
|
+
<html>
|
|
282
|
+
<head><title>500 - Server Error</title></head>
|
|
283
|
+
<body>
|
|
284
|
+
<h1>500 - Server Error</h1>
|
|
285
|
+
<p>An error occurred while processing your request.</p>
|
|
286
|
+
</body>
|
|
287
|
+
</html>
|
|
288
|
+
"""
|
|
289
|
+
return HTMLResponse(content=html, status_code=500)
|
|
290
|
+
|
|
291
|
+
def reload_routes(self) -> None:
|
|
292
|
+
"""Reload all routes (for hot reload)"""
|
|
293
|
+
self._modules_cache.clear()
|
|
294
|
+
self.router = Router(str(self.pages_dir), str(self.templates_dir))
|
|
295
|
+
self.router.scan_pages()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def create_app(
|
|
299
|
+
pages_dir: str = "pages",
|
|
300
|
+
templates_dir: str = "templates",
|
|
301
|
+
public_dir: str = "public",
|
|
302
|
+
out_dir: str = "out",
|
|
303
|
+
debug: bool = False,
|
|
304
|
+
) -> FastAPI:
|
|
305
|
+
"""
|
|
306
|
+
Factory function to create a NextPy application
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
pages_dir: Directory containing page files
|
|
310
|
+
templates_dir: Directory containing Jinja2 templates
|
|
311
|
+
public_dir: Directory containing static files
|
|
312
|
+
out_dir: Directory for SSG output
|
|
313
|
+
debug: Enable debug mode
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
FastAPI application instance
|
|
317
|
+
"""
|
|
318
|
+
nextpy_app = NextPyApp(
|
|
319
|
+
pages_dir=pages_dir,
|
|
320
|
+
templates_dir=templates_dir,
|
|
321
|
+
public_dir=public_dir,
|
|
322
|
+
out_dir=out_dir,
|
|
323
|
+
debug=debug,
|
|
324
|
+
)
|
|
325
|
+
return nextpy_app.app
|
nextpy/server/debug.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Debug utilities for NextPy development
|
|
3
|
+
Provides error tracking, logging, and debug panel support
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import traceback
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
from starlette.responses import HTMLResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ErrorTracker:
|
|
14
|
+
"""Track and format errors for debug panel"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.last_error: Optional[Dict[str, Any]] = None
|
|
18
|
+
|
|
19
|
+
def capture_error(self, exc: Exception, request: Optional[Request] = None) -> Dict[str, Any]:
|
|
20
|
+
"""Capture error details"""
|
|
21
|
+
tb = traceback.format_exc()
|
|
22
|
+
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
23
|
+
|
|
24
|
+
# Get file and line number
|
|
25
|
+
tb_list = traceback.extract_tb(exc_tb)
|
|
26
|
+
last_frame = tb_list[-1] if tb_list else None
|
|
27
|
+
|
|
28
|
+
error_data = {
|
|
29
|
+
"type": exc.__class__.__name__,
|
|
30
|
+
"message": str(exc),
|
|
31
|
+
"traceback": tb,
|
|
32
|
+
"file": last_frame.filename if last_frame else None,
|
|
33
|
+
"line": last_frame.lineno if last_frame else None,
|
|
34
|
+
"path": request.url.path if request else None,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
self.last_error = error_data
|
|
38
|
+
return error_data
|
|
39
|
+
|
|
40
|
+
def get_last_error(self) -> Optional[Dict[str, Any]]:
|
|
41
|
+
"""Get last captured error"""
|
|
42
|
+
return self.last_error
|
|
43
|
+
|
|
44
|
+
def clear_error(self):
|
|
45
|
+
"""Clear last error"""
|
|
46
|
+
self.last_error = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
error_tracker = ErrorTracker()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def render_error_page(error: Dict[str, Any]) -> HTMLResponse:
|
|
53
|
+
"""Render error page with debug info"""
|
|
54
|
+
html = f"""
|
|
55
|
+
<!DOCTYPE html>
|
|
56
|
+
<html>
|
|
57
|
+
<head>
|
|
58
|
+
<title>NextPy Error</title>
|
|
59
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
60
|
+
<style>
|
|
61
|
+
.animate-pulse {{ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }}
|
|
62
|
+
@keyframes pulse {{
|
|
63
|
+
0%, 100% {{ opacity: 1; }}
|
|
64
|
+
50% {{ opacity: .5; }}
|
|
65
|
+
}}
|
|
66
|
+
</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body class="bg-gray-900">
|
|
69
|
+
<div class="fixed bottom-0 left-0 right-0 z-50 bg-red-950 text-red-100 border-t-4 border-red-600 shadow-2xl">
|
|
70
|
+
<div class="max-w-full">
|
|
71
|
+
<div class="flex items-center justify-between p-4 bg-red-900">
|
|
72
|
+
<div class="flex items-center gap-3">
|
|
73
|
+
<span class="text-2xl">⚠️</span>
|
|
74
|
+
<div>
|
|
75
|
+
<h3 class="font-bold text-lg">{error.get('type', 'Error')}</h3>
|
|
76
|
+
<p class="text-sm text-red-200">{error.get('message', 'An error occurred')}</p>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="p-4">
|
|
81
|
+
<h4 class="font-mono font-bold text-sm mb-2">Traceback:</h4>
|
|
82
|
+
<pre class="font-mono text-xs bg-black bg-opacity-30 p-3 rounded overflow-x-auto text-red-100">{error.get('traceback', '')}</pre>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="p-4 border-t border-red-800">
|
|
85
|
+
<h4 class="font-mono font-bold text-sm mb-2">Location:</h4>
|
|
86
|
+
<p class="text-sm"><span class="text-red-300">{error.get('file', 'unknown')}</span> line <span class="font-bold">{error.get('line', '?')}</span></p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|
|
92
|
+
"""
|
|
93
|
+
return HTMLResponse(html, status_code=500)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Middleware - Request/Response middleware for the server
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import Response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NextPyMiddleware(BaseHTTPMiddleware):
|
|
13
|
+
"""
|
|
14
|
+
Core middleware for NextPy applications
|
|
15
|
+
Handles:
|
|
16
|
+
- Request timing
|
|
17
|
+
- Cache headers
|
|
18
|
+
- Security headers
|
|
19
|
+
- HTMX detection
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
23
|
+
start_time = time.time()
|
|
24
|
+
|
|
25
|
+
request.state.is_htmx = request.headers.get("HX-Request") == "true"
|
|
26
|
+
request.state.htmx_target = request.headers.get("HX-Target")
|
|
27
|
+
request.state.htmx_trigger = request.headers.get("HX-Trigger")
|
|
28
|
+
|
|
29
|
+
response = await call_next(request)
|
|
30
|
+
|
|
31
|
+
process_time = time.time() - start_time
|
|
32
|
+
response.headers["X-Process-Time"] = str(process_time)
|
|
33
|
+
|
|
34
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
35
|
+
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
36
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
37
|
+
|
|
38
|
+
if request.state.is_htmx:
|
|
39
|
+
response.headers["HX-Push-Url"] = str(request.url.path)
|
|
40
|
+
|
|
41
|
+
return response
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
45
|
+
"""
|
|
46
|
+
Optional authentication middleware
|
|
47
|
+
Can be added to protect routes
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, app, protected_paths: list = None, login_path: str = "/login"):
|
|
51
|
+
super().__init__(app)
|
|
52
|
+
self.protected_paths = protected_paths or []
|
|
53
|
+
self.login_path = login_path
|
|
54
|
+
|
|
55
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
56
|
+
path = request.url.path
|
|
57
|
+
|
|
58
|
+
for protected in self.protected_paths:
|
|
59
|
+
if path.startswith(protected):
|
|
60
|
+
session = request.cookies.get("session")
|
|
61
|
+
if not session:
|
|
62
|
+
from starlette.responses import RedirectResponse
|
|
63
|
+
return RedirectResponse(
|
|
64
|
+
url=f"{self.login_path}?next={path}",
|
|
65
|
+
status_code=302,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return await call_next(request)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CacheMiddleware(BaseHTTPMiddleware):
|
|
72
|
+
"""
|
|
73
|
+
Caching middleware for static content
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
CACHE_EXTENSIONS = {".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2"}
|
|
77
|
+
|
|
78
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
79
|
+
response = await call_next(request)
|
|
80
|
+
|
|
81
|
+
path = request.url.path
|
|
82
|
+
|
|
83
|
+
if any(path.endswith(ext) for ext in self.CACHE_EXTENSIONS):
|
|
84
|
+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
85
|
+
elif path.startswith("/_nextpy/"):
|
|
86
|
+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
87
|
+
|
|
88
|
+
return response
|
nextpy/utils/__init__.py
ADDED
|
File without changes
|
nextpy/utils/cache.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Caching Utilities
|
|
3
|
+
Simple in-memory cache with TTL support
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional, Dict, Callable
|
|
8
|
+
import functools
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Cache:
|
|
12
|
+
"""Simple in-memory cache with TTL"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._store: Dict[str, tuple] = {}
|
|
16
|
+
|
|
17
|
+
def set(self, key: str, value: Any, ttl: int = 3600):
|
|
18
|
+
"""Set cache value with TTL in seconds"""
|
|
19
|
+
self._store[key] = (value, time.time() + ttl)
|
|
20
|
+
|
|
21
|
+
def get(self, key: str) -> Optional[Any]:
|
|
22
|
+
"""Get cache value if not expired"""
|
|
23
|
+
if key not in self._store:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
value, expiry = self._store[key]
|
|
27
|
+
if time.time() > expiry:
|
|
28
|
+
del self._store[key]
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
def delete(self, key: str):
|
|
34
|
+
"""Delete cache value"""
|
|
35
|
+
if key in self._store:
|
|
36
|
+
del self._store[key]
|
|
37
|
+
|
|
38
|
+
def clear(self):
|
|
39
|
+
"""Clear all cache"""
|
|
40
|
+
self._store.clear()
|
|
41
|
+
|
|
42
|
+
def cleanup_expired(self):
|
|
43
|
+
"""Remove expired entries"""
|
|
44
|
+
now = time.time()
|
|
45
|
+
expired = [k for k, (_, expiry) in self._store.items() if now > expiry]
|
|
46
|
+
for k in expired:
|
|
47
|
+
del self._store[k]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Global cache instance
|
|
51
|
+
_cache = Cache()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_cache() -> Cache:
|
|
55
|
+
"""Get global cache instance"""
|
|
56
|
+
return _cache
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cache_result(ttl: int = 3600):
|
|
60
|
+
"""Decorator to cache function results"""
|
|
61
|
+
def decorator(func: Callable) -> Callable:
|
|
62
|
+
@functools.wraps(func)
|
|
63
|
+
async def async_wrapper(*args, **kwargs):
|
|
64
|
+
key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
|
|
65
|
+
cached = _cache.get(key)
|
|
66
|
+
if cached is not None:
|
|
67
|
+
return cached
|
|
68
|
+
result = await func(*args, **kwargs)
|
|
69
|
+
_cache.set(key, result, ttl)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
@functools.wraps(func)
|
|
73
|
+
def sync_wrapper(*args, **kwargs):
|
|
74
|
+
key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
|
|
75
|
+
cached = _cache.get(key)
|
|
76
|
+
if cached is not None:
|
|
77
|
+
return cached
|
|
78
|
+
result = func(*args, **kwargs)
|
|
79
|
+
_cache.set(key, result, ttl)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
if hasattr(func, '__call__'):
|
|
83
|
+
import inspect
|
|
84
|
+
if inspect.iscoroutinefunction(func):
|
|
85
|
+
return async_wrapper
|
|
86
|
+
|
|
87
|
+
return sync_wrapper
|
|
88
|
+
|
|
89
|
+
return decorator
|
nextpy/utils/email.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Email Utilities
|
|
3
|
+
Send emails via SMTP
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import smtplib
|
|
7
|
+
from email.mime.text import MIMEText
|
|
8
|
+
from email.mime.multipart import MIMEMultipart
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
from nextpy.config import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def send_email(
|
|
14
|
+
to: List[str],
|
|
15
|
+
subject: str,
|
|
16
|
+
html_content: str,
|
|
17
|
+
text_content: Optional[str] = None,
|
|
18
|
+
) -> bool:
|
|
19
|
+
"""Send email via SMTP"""
|
|
20
|
+
try:
|
|
21
|
+
msg = MIMEMultipart("alternative")
|
|
22
|
+
msg["Subject"] = subject
|
|
23
|
+
msg["From"] = settings.mail_username
|
|
24
|
+
msg["To"] = ", ".join(to)
|
|
25
|
+
|
|
26
|
+
if text_content:
|
|
27
|
+
msg.attach(MIMEText(text_content, "plain"))
|
|
28
|
+
msg.attach(MIMEText(html_content, "html"))
|
|
29
|
+
|
|
30
|
+
with smtplib.SMTP(settings.mail_server, settings.mail_port) as server:
|
|
31
|
+
server.starttls()
|
|
32
|
+
server.login(settings.mail_username, settings.mail_password)
|
|
33
|
+
server.sendmail(settings.mail_username, to, msg.as_string())
|
|
34
|
+
|
|
35
|
+
return True
|
|
36
|
+
except Exception as e:
|
|
37
|
+
print(f"Email failed: {e}")
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def send_welcome_email(email: str, name: str) -> bool:
|
|
42
|
+
"""Send welcome email"""
|
|
43
|
+
html = f"""
|
|
44
|
+
<h2>Welcome {name}!</h2>
|
|
45
|
+
<p>Thanks for joining NextPy.</p>
|
|
46
|
+
<p>Start building amazing apps today.</p>
|
|
47
|
+
"""
|
|
48
|
+
return await send_email([email], "Welcome to NextPy", html)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def send_reset_password_email(email: str, reset_link: str) -> bool:
|
|
52
|
+
"""Send password reset email"""
|
|
53
|
+
html = f"""
|
|
54
|
+
<h2>Reset Your Password</h2>
|
|
55
|
+
<p>Click the link below to reset your password:</p>
|
|
56
|
+
<a href="{reset_link}">Reset Password</a>
|
|
57
|
+
<p>This link expires in 24 hours.</p>
|
|
58
|
+
"""
|
|
59
|
+
return await send_email([email], "Reset Your Password", html)
|