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/core/router.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Router - File-based routing system inspired by Next.js
|
|
3
|
+
Supports:
|
|
4
|
+
- File-based routing (pages/index.py -> /)
|
|
5
|
+
- Dynamic routes (pages/[slug].py -> /:slug)
|
|
6
|
+
- Nested routes (pages/blog/[id].py -> /blog/:id)
|
|
7
|
+
- Catch-all routes (pages/[...path].py -> /*)
|
|
8
|
+
- API routes (pages/api/*.py)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import importlib.util
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Callable, Any, Tuple
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RouteParams(BaseModel):
|
|
21
|
+
"""Type-safe route parameters"""
|
|
22
|
+
params: Dict[str, str] = {}
|
|
23
|
+
query: Dict[str, str] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Route:
|
|
28
|
+
"""Represents a single route in the application"""
|
|
29
|
+
path: str
|
|
30
|
+
file_path: Path
|
|
31
|
+
handler: Optional[Callable] = None
|
|
32
|
+
is_dynamic: bool = False
|
|
33
|
+
is_api: bool = False
|
|
34
|
+
is_catch_all: bool = False
|
|
35
|
+
param_names: List[str] = field(default_factory=list)
|
|
36
|
+
pattern: Optional[re.Pattern] = None
|
|
37
|
+
|
|
38
|
+
def matches(self, url_path: str) -> Optional[Dict[str, str]]:
|
|
39
|
+
"""Check if this route matches the given URL path"""
|
|
40
|
+
if self.pattern:
|
|
41
|
+
match = self.pattern.match(url_path)
|
|
42
|
+
if match:
|
|
43
|
+
return match.groupdict()
|
|
44
|
+
elif self.path == url_path:
|
|
45
|
+
return {}
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class DynamicRoute(Route):
|
|
51
|
+
"""A route with dynamic segments like [slug] or [...path]"""
|
|
52
|
+
is_dynamic: bool = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Router:
|
|
56
|
+
"""
|
|
57
|
+
File-based router that scans the pages directory
|
|
58
|
+
and creates routes similar to Next.js
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, pages_dir: str = "pages", templates_dir: str = "templates"):
|
|
62
|
+
self.pages_dir = Path(pages_dir)
|
|
63
|
+
self.templates_dir = Path(templates_dir)
|
|
64
|
+
self.routes: List[Route] = []
|
|
65
|
+
self.api_routes: List[Route] = []
|
|
66
|
+
self._route_cache: Dict[str, Route] = {}
|
|
67
|
+
|
|
68
|
+
def scan_pages(self) -> None:
|
|
69
|
+
"""Scan the pages directory and register all routes"""
|
|
70
|
+
if not self.pages_dir.exists():
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
for file_path in self.pages_dir.rglob("*.py"):
|
|
74
|
+
if file_path.name.startswith("_"):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
route = self._create_route_from_file(file_path)
|
|
78
|
+
if route:
|
|
79
|
+
if route.is_api:
|
|
80
|
+
self.api_routes.append(route)
|
|
81
|
+
else:
|
|
82
|
+
self.routes.append(route)
|
|
83
|
+
|
|
84
|
+
self._sort_routes()
|
|
85
|
+
|
|
86
|
+
def _create_route_from_file(self, file_path: Path) -> Optional[Route]:
|
|
87
|
+
"""Create a Route object from a Python file"""
|
|
88
|
+
relative_path = file_path.relative_to(self.pages_dir)
|
|
89
|
+
parts = list(relative_path.parts)
|
|
90
|
+
|
|
91
|
+
is_api = parts[0] == "api" if parts else False
|
|
92
|
+
|
|
93
|
+
route_parts = []
|
|
94
|
+
param_names = []
|
|
95
|
+
is_dynamic = False
|
|
96
|
+
is_catch_all = False
|
|
97
|
+
|
|
98
|
+
for part in parts:
|
|
99
|
+
if part.endswith(".py"):
|
|
100
|
+
part = part[:-3]
|
|
101
|
+
|
|
102
|
+
if part == "index":
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
catch_all_match = re.match(r"\[\.\.\.(\w+)\]", part)
|
|
106
|
+
dynamic_match = re.match(r"\[(\w+)\]", part)
|
|
107
|
+
|
|
108
|
+
if catch_all_match:
|
|
109
|
+
param_name = catch_all_match.group(1)
|
|
110
|
+
param_names.append(param_name)
|
|
111
|
+
route_parts.append(f"(?P<{param_name}>.+)")
|
|
112
|
+
is_dynamic = True
|
|
113
|
+
is_catch_all = True
|
|
114
|
+
elif dynamic_match:
|
|
115
|
+
param_name = dynamic_match.group(1)
|
|
116
|
+
param_names.append(param_name)
|
|
117
|
+
route_parts.append(f"(?P<{param_name}>[^/]+)")
|
|
118
|
+
is_dynamic = True
|
|
119
|
+
else:
|
|
120
|
+
route_parts.append(re.escape(part))
|
|
121
|
+
|
|
122
|
+
if is_api and route_parts and route_parts[0] == "api":
|
|
123
|
+
route_parts = route_parts[1:]
|
|
124
|
+
|
|
125
|
+
path = "/" + "/".join(route_parts) if route_parts else "/"
|
|
126
|
+
|
|
127
|
+
if is_api:
|
|
128
|
+
path = "/api" + path if path != "/" else "/api"
|
|
129
|
+
|
|
130
|
+
pattern = None
|
|
131
|
+
if is_dynamic:
|
|
132
|
+
pattern_str = "^" + path.replace("/", r"\/") + "$"
|
|
133
|
+
pattern_str = re.sub(r"\\\(\?P", "(?P", pattern_str)
|
|
134
|
+
pattern_str = pattern_str.replace(r"\[", "[").replace(r"\]", "]")
|
|
135
|
+
pattern_str = pattern_str.replace(r"\+", "+")
|
|
136
|
+
try:
|
|
137
|
+
pattern = re.compile(pattern_str)
|
|
138
|
+
except re.error:
|
|
139
|
+
pattern = re.compile("^" + path + "$")
|
|
140
|
+
|
|
141
|
+
handler = self._load_handler(file_path)
|
|
142
|
+
|
|
143
|
+
route_class = DynamicRoute if is_dynamic else Route
|
|
144
|
+
return route_class(
|
|
145
|
+
path=path,
|
|
146
|
+
file_path=file_path,
|
|
147
|
+
handler=handler,
|
|
148
|
+
is_dynamic=is_dynamic,
|
|
149
|
+
is_api=is_api,
|
|
150
|
+
is_catch_all=is_catch_all,
|
|
151
|
+
param_names=param_names,
|
|
152
|
+
pattern=pattern,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _load_handler(self, file_path: Path) -> Optional[Callable]:
|
|
156
|
+
"""Dynamically load the handler function from a page file"""
|
|
157
|
+
try:
|
|
158
|
+
spec = importlib.util.spec_from_file_location(
|
|
159
|
+
file_path.stem,
|
|
160
|
+
file_path
|
|
161
|
+
)
|
|
162
|
+
if spec and spec.loader:
|
|
163
|
+
module = importlib.util.module_from_spec(spec)
|
|
164
|
+
spec.loader.exec_module(module)
|
|
165
|
+
|
|
166
|
+
for func_name in ["handler", "page", "get", "post", "default"]:
|
|
167
|
+
if hasattr(module, func_name):
|
|
168
|
+
return getattr(module, func_name)
|
|
169
|
+
|
|
170
|
+
if hasattr(module, "Page"):
|
|
171
|
+
return module.Page
|
|
172
|
+
|
|
173
|
+
except Exception as e:
|
|
174
|
+
print(f"Error loading handler from {file_path}: {e}")
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def _sort_routes(self) -> None:
|
|
179
|
+
"""Sort routes so static routes come before dynamic ones"""
|
|
180
|
+
def route_priority(route: Route) -> Tuple[int, int, str]:
|
|
181
|
+
if route.is_catch_all:
|
|
182
|
+
return (2, len(route.param_names), route.path)
|
|
183
|
+
elif route.is_dynamic:
|
|
184
|
+
return (1, len(route.param_names), route.path)
|
|
185
|
+
else:
|
|
186
|
+
return (0, 0, route.path)
|
|
187
|
+
|
|
188
|
+
self.routes.sort(key=route_priority)
|
|
189
|
+
self.api_routes.sort(key=route_priority)
|
|
190
|
+
|
|
191
|
+
def match(self, url_path: str) -> Optional[Tuple[Route, Dict[str, str]]]:
|
|
192
|
+
"""Find a route that matches the given URL path"""
|
|
193
|
+
url_path = url_path.rstrip("/") or "/"
|
|
194
|
+
|
|
195
|
+
if url_path in self._route_cache:
|
|
196
|
+
route = self._route_cache[url_path]
|
|
197
|
+
params = route.matches(url_path) or {}
|
|
198
|
+
return (route, params)
|
|
199
|
+
|
|
200
|
+
routes = self.api_routes if url_path.startswith("/api") else self.routes
|
|
201
|
+
|
|
202
|
+
for route in routes:
|
|
203
|
+
params = route.matches(url_path)
|
|
204
|
+
if params is not None:
|
|
205
|
+
if not route.is_dynamic:
|
|
206
|
+
self._route_cache[url_path] = route
|
|
207
|
+
return (route, params)
|
|
208
|
+
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def get_all_routes(self) -> List[Route]:
|
|
212
|
+
"""Get all registered routes"""
|
|
213
|
+
return self.routes + self.api_routes
|
|
214
|
+
|
|
215
|
+
def get_static_routes(self) -> List[Route]:
|
|
216
|
+
"""Get only static (non-dynamic) routes for SSG"""
|
|
217
|
+
return [r for r in self.routes if not r.is_dynamic]
|
|
218
|
+
|
|
219
|
+
def reload_route(self, file_path: Path) -> None:
|
|
220
|
+
"""Reload a specific route (for hot reload)"""
|
|
221
|
+
self.routes = [r for r in self.routes if r.file_path != file_path]
|
|
222
|
+
self.api_routes = [r for r in self.api_routes if r.file_path != file_path]
|
|
223
|
+
self._route_cache.clear()
|
|
224
|
+
|
|
225
|
+
if file_path.exists():
|
|
226
|
+
route = self._create_route_from_file(file_path)
|
|
227
|
+
if route:
|
|
228
|
+
if route.is_api:
|
|
229
|
+
self.api_routes.append(route)
|
|
230
|
+
else:
|
|
231
|
+
self.routes.append(route)
|
|
232
|
+
|
|
233
|
+
self._sort_routes()
|
nextpy/core/sync.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Sync Support
|
|
3
|
+
Allow pages to be written with sync functions instead of async
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Callable, Any, Dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sync_to_async(func: Callable) -> Callable:
|
|
12
|
+
"""Convert sync function to async"""
|
|
13
|
+
if inspect.iscoroutinefunction(func):
|
|
14
|
+
return func
|
|
15
|
+
|
|
16
|
+
async def wrapper(*args, **kwargs):
|
|
17
|
+
loop = asyncio.get_event_loop()
|
|
18
|
+
return await loop.run_in_executor(None, func, *args, **kwargs)
|
|
19
|
+
|
|
20
|
+
return wrapper
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def run_sync(func: Callable, *args, **kwargs) -> Any:
|
|
24
|
+
"""Run sync function in async context"""
|
|
25
|
+
if inspect.iscoroutinefunction(func):
|
|
26
|
+
return await func(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
loop = asyncio.get_event_loop()
|
|
29
|
+
return await loop.run_in_executor(None, func, *args, **kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def supports_sync(func: Callable) -> bool:
|
|
33
|
+
"""Check if function supports sync"""
|
|
34
|
+
return not inspect.iscoroutinefunction(func)
|
nextpy/db.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Database Layer
|
|
3
|
+
Support for SQLite, PostgreSQL, MySQL with SQLAlchemy ORM
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
from sqlalchemy import create_engine
|
|
9
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
10
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
11
|
+
|
|
12
|
+
Base = declarative_base()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DatabaseConfig:
|
|
16
|
+
"""Database configuration manager"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, database_url: Optional[str] = None):
|
|
19
|
+
self.database_url = database_url or os.getenv("DATABASE_URL", "sqlite:///./nextpy.db")
|
|
20
|
+
self.echo = os.getenv("DB_ECHO", "false").lower() == "true"
|
|
21
|
+
self.pool_size = int(os.getenv("DB_POOL_SIZE", "5"))
|
|
22
|
+
self.max_overflow = int(os.getenv("DB_MAX_OVERFLOW", "10"))
|
|
23
|
+
|
|
24
|
+
def get_engine(self):
|
|
25
|
+
"""Get SQLAlchemy engine"""
|
|
26
|
+
if self.database_url.startswith("sqlite"):
|
|
27
|
+
return create_engine(
|
|
28
|
+
self.database_url,
|
|
29
|
+
echo=self.echo,
|
|
30
|
+
connect_args={"check_same_thread": False}
|
|
31
|
+
)
|
|
32
|
+
else:
|
|
33
|
+
return create_engine(
|
|
34
|
+
self.database_url,
|
|
35
|
+
echo=self.echo,
|
|
36
|
+
pool_size=self.pool_size,
|
|
37
|
+
max_overflow=self.max_overflow,
|
|
38
|
+
pool_pre_ping=True
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Database:
|
|
43
|
+
"""Database manager with connection pooling"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, database_url: Optional[str] = None):
|
|
46
|
+
self.config = DatabaseConfig(database_url)
|
|
47
|
+
self.engine = self.config.get_engine()
|
|
48
|
+
self.SessionLocal = sessionmaker(bind=self.engine, expire_on_commit=False)
|
|
49
|
+
|
|
50
|
+
def get_session(self) -> Session:
|
|
51
|
+
"""Get database session"""
|
|
52
|
+
return self.SessionLocal()
|
|
53
|
+
|
|
54
|
+
def create_tables(self):
|
|
55
|
+
"""Create all tables"""
|
|
56
|
+
Base.metadata.create_all(self.engine)
|
|
57
|
+
|
|
58
|
+
def drop_tables(self):
|
|
59
|
+
"""Drop all tables (development only)"""
|
|
60
|
+
Base.metadata.drop_all(self.engine)
|
|
61
|
+
|
|
62
|
+
def close(self):
|
|
63
|
+
"""Close connection pool"""
|
|
64
|
+
self.engine.dispose()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Global database instance
|
|
68
|
+
_db: Optional[Database] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def init_db(database_url: Optional[str] = None) -> Database:
|
|
72
|
+
"""Initialize global database instance"""
|
|
73
|
+
global _db
|
|
74
|
+
_db = Database(database_url)
|
|
75
|
+
_db.create_tables()
|
|
76
|
+
return _db
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_db() -> Database:
|
|
80
|
+
"""Get global database instance"""
|
|
81
|
+
if _db is None:
|
|
82
|
+
raise RuntimeError("Database not initialized. Call init_db() first.")
|
|
83
|
+
return _db
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_session() -> Session:
|
|
87
|
+
"""Get database session"""
|
|
88
|
+
return get_db().get_session()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Models example
|
|
92
|
+
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean
|
|
93
|
+
from datetime import datetime
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class User(Base):
|
|
97
|
+
"""User model"""
|
|
98
|
+
__tablename__ = "users"
|
|
99
|
+
|
|
100
|
+
id = Column(Integer, primary_key=True)
|
|
101
|
+
email = Column(String(255), unique=True, index=True)
|
|
102
|
+
username = Column(String(255), unique=True, index=True)
|
|
103
|
+
full_name = Column(String(255))
|
|
104
|
+
hashed_password = Column(String(255))
|
|
105
|
+
is_active = Column(Boolean, default=True)
|
|
106
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Post(Base):
|
|
110
|
+
"""Blog post model"""
|
|
111
|
+
__tablename__ = "posts"
|
|
112
|
+
|
|
113
|
+
id = Column(Integer, primary_key=True)
|
|
114
|
+
title = Column(String(255), index=True)
|
|
115
|
+
slug = Column(String(255), unique=True, index=True)
|
|
116
|
+
content = Column(Text)
|
|
117
|
+
excerpt = Column(String(500))
|
|
118
|
+
author_id = Column(Integer, index=True)
|
|
119
|
+
published = Column(Boolean, default=False)
|
|
120
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
121
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
nextpy/dev_server.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Enhanced Development Server with hot reload and optimization
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from watchdog.observers import Observer
|
|
7
|
+
from watchdog.events import FileSystemEventHandler
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, List, Optional, Dict, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OptimizedDevServer(FileSystemEventHandler):
|
|
13
|
+
"""Development server with file watching and hot reload"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, on_change: Callable[[str], None]):
|
|
16
|
+
self.on_change = on_change
|
|
17
|
+
self.debounce_timer: Optional[asyncio.Timer] = None
|
|
18
|
+
self.debounce_delay: float = 0.5 # seconds
|
|
19
|
+
|
|
20
|
+
def on_modified(self, event: Any) -> None:
|
|
21
|
+
"""Handle file modification"""
|
|
22
|
+
if event.is_directory:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
# Debounce rapid changes
|
|
26
|
+
if self.debounce_timer:
|
|
27
|
+
self.debounce_timer.cancel()
|
|
28
|
+
|
|
29
|
+
self.debounce_timer = asyncio.Timer(
|
|
30
|
+
self.debounce_delay,
|
|
31
|
+
lambda: self.on_change(event.src_path)
|
|
32
|
+
)
|
|
33
|
+
self.debounce_timer.start()
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def watch_directories(paths: List[Path], on_change: Callable[[str], None]) -> Observer:
|
|
37
|
+
"""Watch multiple directories for changes"""
|
|
38
|
+
observer = Observer()
|
|
39
|
+
handler = OptimizedDevServer(on_change)
|
|
40
|
+
|
|
41
|
+
for path in paths:
|
|
42
|
+
observer.schedule(handler, str(path), recursive=True)
|
|
43
|
+
|
|
44
|
+
observer.start()
|
|
45
|
+
return observer
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DevServerStats:
|
|
49
|
+
"""Track development server statistics"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.hot_reloads: int = 0
|
|
53
|
+
self.build_time: float = 0
|
|
54
|
+
self.files_changed: List[str] = []
|
|
55
|
+
|
|
56
|
+
def record_reload(self, file_path: str, build_time: float) -> None:
|
|
57
|
+
"""Record a hot reload event"""
|
|
58
|
+
self.hot_reloads += 1
|
|
59
|
+
self.build_time = build_time
|
|
60
|
+
self.files_changed.append(file_path)
|
|
61
|
+
|
|
62
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
63
|
+
"""Get server statistics"""
|
|
64
|
+
return {
|
|
65
|
+
"hot_reloads": self.hot_reloads,
|
|
66
|
+
"last_build_time_ms": round(self.build_time * 1000, 2),
|
|
67
|
+
"files_changed": len(self.files_changed),
|
|
68
|
+
"recent_changes": self.files_changed[-5:]
|
|
69
|
+
}
|
nextpy/dev_tools.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Development Tools - Generators and scaffolding
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, List, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PageGenerator:
|
|
10
|
+
"""Generate page files from templates"""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def create_page(name: str, pages_dir: Path = Path("pages")) -> Tuple[Path, Path]:
|
|
14
|
+
"""Generate a new page file"""
|
|
15
|
+
name_clean = name.replace("-", "_").lower()
|
|
16
|
+
page_file = pages_dir / f"{name_clean}.py"
|
|
17
|
+
|
|
18
|
+
content = f'''"""
|
|
19
|
+
{name} page
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def get_template():
|
|
23
|
+
return "{name_clean}.html"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def get_server_side_props(context):
|
|
27
|
+
return {{
|
|
28
|
+
"props": {{
|
|
29
|
+
"title": "{name}",
|
|
30
|
+
"description": "Page description"
|
|
31
|
+
}}
|
|
32
|
+
}}
|
|
33
|
+
'''
|
|
34
|
+
|
|
35
|
+
page_file.write_text(content)
|
|
36
|
+
|
|
37
|
+
# Create template
|
|
38
|
+
template_file = Path("templates") / f"{name_clean}.html"
|
|
39
|
+
template_content = f'''{% extends "_base.html" %}
|
|
40
|
+
|
|
41
|
+
{% block content %}
|
|
42
|
+
<div class="max-w-6xl mx-auto py-12 px-4">
|
|
43
|
+
<h1 class="text-4xl font-bold mb-4">{{{{ title }}}}</h1>
|
|
44
|
+
<p class="text-gray-600 mb-8">{{{{ description }}}}</p>
|
|
45
|
+
|
|
46
|
+
<!-- Your content here -->
|
|
47
|
+
</div>
|
|
48
|
+
{% endblock %}
|
|
49
|
+
'''
|
|
50
|
+
template_file.write_text(template_content)
|
|
51
|
+
|
|
52
|
+
return page_file, template_file
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APIGenerator:
|
|
56
|
+
"""Generate API route files"""
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def create_api(name: str, methods: Optional[List[str]] = None, api_dir: Path = Path("pages/api")) -> Path:
|
|
60
|
+
"""Generate API endpoint"""
|
|
61
|
+
if methods is None:
|
|
62
|
+
methods = ["GET"]
|
|
63
|
+
|
|
64
|
+
name_clean = name.replace("-", "_").lower()
|
|
65
|
+
api_file = api_dir / f"{name_clean}.py"
|
|
66
|
+
|
|
67
|
+
method_stubs: List[str] = []
|
|
68
|
+
for method in methods:
|
|
69
|
+
method_lower = method.lower()
|
|
70
|
+
if method == "GET":
|
|
71
|
+
method_stubs.append(f'''async def {method_lower}(request):
|
|
72
|
+
"""GET /{name_clean}"""
|
|
73
|
+
return {{"data": "hello"}}
|
|
74
|
+
''')
|
|
75
|
+
elif method == "POST":
|
|
76
|
+
method_stubs.append(f'''async def {method_lower}(request):
|
|
77
|
+
"""POST /{name_clean}"""
|
|
78
|
+
data = await request.json()
|
|
79
|
+
return {{"created": data}}
|
|
80
|
+
''')
|
|
81
|
+
elif method in ["PUT", "DELETE"]:
|
|
82
|
+
method_stubs.append(f'''async def {method_lower}(request):
|
|
83
|
+
"""{method} /{name_clean}"""
|
|
84
|
+
return {{"{method_lower.lower()}_id": 1}}
|
|
85
|
+
''')
|
|
86
|
+
|
|
87
|
+
content = f'''"""
|
|
88
|
+
{name} API endpoint
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
{chr(10).join(method_stubs)}
|
|
92
|
+
'''
|
|
93
|
+
|
|
94
|
+
api_file.write_text(content)
|
|
95
|
+
return api_file
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ComponentGenerator:
|
|
99
|
+
"""Generate component files"""
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def create_component(name: str, templates_dir: Path = Path("templates/components")) -> Path:
|
|
103
|
+
"""Generate a new component"""
|
|
104
|
+
name_clean = name.replace("-", "_").lower()
|
|
105
|
+
component_file = templates_dir / f"{name_clean}.html"
|
|
106
|
+
|
|
107
|
+
content = f'''{{%- macro {name_clean}(label, items=[], **kwargs) -}}
|
|
108
|
+
<!-- {name} component -->
|
|
109
|
+
<div class="component-{name_clean}">
|
|
110
|
+
<h3>{{{{ label }}}}</h3>
|
|
111
|
+
{{% for item in items %}}
|
|
112
|
+
<div class="item">{{{{ item }}}}</div>
|
|
113
|
+
{{% endfor %}}
|
|
114
|
+
</div>
|
|
115
|
+
{{%- endmacro %}}
|
|
116
|
+
'''
|
|
117
|
+
|
|
118
|
+
component_file.write_text(content)
|
|
119
|
+
return component_file
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ModelGenerator:
|
|
123
|
+
"""Generate database models"""
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def create_model(name: str, fields: Optional[dict] = None, models_dir: Path = Path("models")) -> Path:
|
|
127
|
+
"""Generate database model"""
|
|
128
|
+
if fields is None:
|
|
129
|
+
fields = {"id": "Integer, primary_key=True", "name": "String(255)"}
|
|
130
|
+
|
|
131
|
+
models_dir.mkdir(exist_ok=True)
|
|
132
|
+
model_file = models_dir / f"{name.lower()}.py"
|
|
133
|
+
|
|
134
|
+
field_stubs: List[str] = []
|
|
135
|
+
for field_name, field_type in fields.items():
|
|
136
|
+
field_stubs.append(f" {field_name} = Column({field_type})")
|
|
137
|
+
|
|
138
|
+
content = f'''"""
|
|
139
|
+
{name} model
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
from nextpy.db import Base
|
|
143
|
+
from sqlalchemy import Column, String, Integer, DateTime, Text
|
|
144
|
+
from datetime import datetime
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class {name}(Base):
|
|
148
|
+
"""{{docstring}}"""
|
|
149
|
+
__tablename__ = "{name.lower()}s"
|
|
150
|
+
|
|
151
|
+
{chr(10).join(field_stubs)}
|
|
152
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
153
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
154
|
+
'''
|
|
155
|
+
|
|
156
|
+
model_file.write_text(content)
|
|
157
|
+
return model_file
|
nextpy/errors.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NextPy Error Handling
|
|
3
|
+
Custom exceptions and error utilities
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NextPyException(Exception):
|
|
10
|
+
"""Base NextPy exception"""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RouteNotFound(NextPyException):
|
|
15
|
+
"""Route not found"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TemplateNotFound(NextPyException):
|
|
20
|
+
"""Template not found"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DatabaseError(NextPyException):
|
|
25
|
+
"""Database connection error"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthenticationError(NextPyException):
|
|
30
|
+
"""Authentication failed"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationError(NextPyException):
|
|
35
|
+
"""Validation failed"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, errors: Optional[Dict[str, str]] = None):
|
|
38
|
+
self.message = message
|
|
39
|
+
self.errors = errors or {}
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(NextPyException):
|
|
44
|
+
"""Rate limit exceeded"""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def handle_error(error: Exception) -> Dict[str, Any]:
|
|
49
|
+
"""Convert error to response dict"""
|
|
50
|
+
if isinstance(error, ValidationError):
|
|
51
|
+
return {
|
|
52
|
+
"error": error.message,
|
|
53
|
+
"details": error.errors,
|
|
54
|
+
"status": 400
|
|
55
|
+
}
|
|
56
|
+
elif isinstance(error, AuthenticationError):
|
|
57
|
+
return {
|
|
58
|
+
"error": str(error),
|
|
59
|
+
"status": 401
|
|
60
|
+
}
|
|
61
|
+
elif isinstance(error, RateLimitError):
|
|
62
|
+
return {
|
|
63
|
+
"error": str(error),
|
|
64
|
+
"status": 429
|
|
65
|
+
}
|
|
66
|
+
else:
|
|
67
|
+
return {
|
|
68
|
+
"error": str(error),
|
|
69
|
+
"status": 500
|
|
70
|
+
}
|