caprail 0.1.0__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.
- caprail-0.1.0/PKG-INFO +36 -0
- caprail-0.1.0/README.md +0 -0
- caprail-0.1.0/pyproject.toml +58 -0
- caprail-0.1.0/src/caprail/__init__.py +54 -0
- caprail-0.1.0/src/caprail/application.py +206 -0
- caprail-0.1.0/src/caprail/auth/__init__.py +3 -0
- caprail-0.1.0/src/caprail/auth/auth_manager.py +106 -0
- caprail-0.1.0/src/caprail/console/__init__.py +0 -0
- caprail-0.1.0/src/caprail/console/commands/__init__.py +33 -0
- caprail-0.1.0/src/caprail/console/commands/key_generate.py +31 -0
- caprail-0.1.0/src/caprail/console/commands/make_controller.py +60 -0
- caprail-0.1.0/src/caprail/console/commands/make_middleware.py +37 -0
- caprail-0.1.0/src/caprail/console/commands/make_migration.py +51 -0
- caprail-0.1.0/src/caprail/console/commands/make_model.py +43 -0
- caprail-0.1.0/src/caprail/console/commands/make_provider.py +36 -0
- caprail-0.1.0/src/caprail/console/commands/make_request.py +38 -0
- caprail-0.1.0/src/caprail/console/commands/migrate_cmd.py +44 -0
- caprail-0.1.0/src/caprail/console/commands/migrate_fresh.py +24 -0
- caprail-0.1.0/src/caprail/console/commands/migrate_rollback.py +26 -0
- caprail-0.1.0/src/caprail/console/commands/migrate_status.py +31 -0
- caprail-0.1.0/src/caprail/console/commands/new_project.py +629 -0
- caprail-0.1.0/src/caprail/console/commands/route_list.py +37 -0
- caprail-0.1.0/src/caprail/console/commands/serve_cmd.py +53 -0
- caprail-0.1.0/src/caprail/console/kernel.py +69 -0
- caprail-0.1.0/src/caprail/foundation/__init__.py +4 -0
- caprail-0.1.0/src/caprail/foundation/container.py +169 -0
- caprail-0.1.0/src/caprail/foundation/service_provider.py +59 -0
- caprail-0.1.0/src/caprail/http/__init__.py +14 -0
- caprail-0.1.0/src/caprail/http/exceptions.py +9 -0
- caprail-0.1.0/src/caprail/http/form_request.py +82 -0
- caprail-0.1.0/src/caprail/http/middleware.py +70 -0
- caprail-0.1.0/src/caprail/http/middleware_builtin.py +132 -0
- caprail-0.1.0/src/caprail/http/request.py +277 -0
- caprail-0.1.0/src/caprail/http/response.py +229 -0
- caprail-0.1.0/src/caprail/http/uploaded_file.py +30 -0
- caprail-0.1.0/src/caprail/orm/__init__.py +16 -0
- caprail-0.1.0/src/caprail/orm/migrations.py +406 -0
- caprail-0.1.0/src/caprail/orm/model.py +312 -0
- caprail-0.1.0/src/caprail/orm/query_builder.py +292 -0
- caprail-0.1.0/src/caprail/orm/relations/__init__.py +0 -0
- caprail-0.1.0/src/caprail/orm/relations.py +193 -0
- caprail-0.1.0/src/caprail/py.typed +0 -0
- caprail-0.1.0/src/caprail/routing/__init__.py +3 -0
- caprail-0.1.0/src/caprail/routing/router.py +238 -0
- caprail-0.1.0/src/caprail/support/__init__.py +3 -0
- caprail-0.1.0/src/caprail/support/helpers.py +82 -0
- caprail-0.1.0/src/caprail/view/__init__.py +3 -0
- caprail-0.1.0/src/caprail/view/engine.py +267 -0
caprail-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: caprail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A full-stack async Python web framework inspired by Laravel
|
|
5
|
+
Keywords: web,framework,async,asgi,laravel
|
|
6
|
+
Author: Mark Railton
|
|
7
|
+
Author-email: Mark Railton <mark.railton@outlook.ie>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.34
|
|
17
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
18
|
+
Requires-Dist: aiosqlite>=0.21
|
|
19
|
+
Requires-Dist: asyncpg>=0.30
|
|
20
|
+
Requires-Dist: aiomysql>=0.2
|
|
21
|
+
Requires-Dist: jinja2>=3.1
|
|
22
|
+
Requires-Dist: typer>=0.15
|
|
23
|
+
Requires-Dist: python-dotenv>=1.1
|
|
24
|
+
Requires-Dist: itsdangerous>=2.2
|
|
25
|
+
Requires-Dist: python-multipart>=0.0.20
|
|
26
|
+
Requires-Dist: aiofiles>=24.1
|
|
27
|
+
Requires-Dist: click>=8.1
|
|
28
|
+
Requires-Dist: rich>=13.9
|
|
29
|
+
Requires-Dist: httpx>=0.28
|
|
30
|
+
Requires-Dist: pydantic>=2.10
|
|
31
|
+
Requires-Python: >=3.14
|
|
32
|
+
Project-URL: Homepage, https://github.com/caprail/caprail
|
|
33
|
+
Project-URL: Repository, https://github.com/caprail/caprail
|
|
34
|
+
Project-URL: Documentation, https://caprail.dev
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
caprail-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "caprail"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A full-stack async Python web framework inspired by Laravel"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Mark Railton", email = "mark.railton@outlook.ie" }
|
|
9
|
+
]
|
|
10
|
+
keywords = ["web", "framework", "async", "asgi", "laravel"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: AsyncIO",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.14"
|
|
21
|
+
dependencies = [
|
|
22
|
+
"uvicorn[standard]>=0.34",
|
|
23
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
24
|
+
"aiosqlite>=0.21",
|
|
25
|
+
"asyncpg>=0.30",
|
|
26
|
+
"aiomysql>=0.2",
|
|
27
|
+
"jinja2>=3.1",
|
|
28
|
+
"typer>=0.15",
|
|
29
|
+
"python-dotenv>=1.1",
|
|
30
|
+
"itsdangerous>=2.2",
|
|
31
|
+
"python-multipart>=0.0.20",
|
|
32
|
+
"aiofiles>=24.1",
|
|
33
|
+
"click>=8.1",
|
|
34
|
+
"rich>=13.9",
|
|
35
|
+
"httpx>=0.28",
|
|
36
|
+
"pydantic>=2.10",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
caprail = "caprail.console.kernel:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/caprail/caprail"
|
|
44
|
+
Repository = "https://github.com/caprail/caprail"
|
|
45
|
+
Documentation = "https://caprail.dev"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.10.11,<0.11.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
dev = [
|
|
53
|
+
"pytest>=8.3",
|
|
54
|
+
"pytest-asyncio>=0.25",
|
|
55
|
+
"pytest-cov>=6.0",
|
|
56
|
+
"httpx>=0.28",
|
|
57
|
+
]
|
|
58
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Caprail — the Laravel-inspired Python web framework.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from caprail import Application
|
|
7
|
+
from caprail.http import Response
|
|
8
|
+
from caprail.routing import Router
|
|
9
|
+
|
|
10
|
+
app = Application()
|
|
11
|
+
|
|
12
|
+
@app.router.get("/")
|
|
13
|
+
async def home(request):
|
|
14
|
+
return Response("Hello, Caprail!")
|
|
15
|
+
|
|
16
|
+
application = app.make_asgi()
|
|
17
|
+
"""
|
|
18
|
+
from caprail.application import Application
|
|
19
|
+
from caprail.http.request import Request
|
|
20
|
+
from caprail.http.response import Response, StreamingResponse, FileResponse
|
|
21
|
+
from caprail.http.middleware import Middleware, Pipeline
|
|
22
|
+
from caprail.http.exceptions import HttpException
|
|
23
|
+
from caprail.foundation.container import Container, BindingResolutionError
|
|
24
|
+
from caprail.foundation.service_provider import ServiceProvider
|
|
25
|
+
from caprail.routing.router import Router, Route
|
|
26
|
+
from caprail.view.engine import ViewEngine
|
|
27
|
+
from caprail.orm.model import Model, configure_database
|
|
28
|
+
from caprail.orm.migrations import Migration, Schema, Blueprint
|
|
29
|
+
from caprail.auth.auth_manager import Auth
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Application",
|
|
35
|
+
"Request",
|
|
36
|
+
"Response",
|
|
37
|
+
"StreamingResponse",
|
|
38
|
+
"FileResponse",
|
|
39
|
+
"Middleware",
|
|
40
|
+
"Pipeline",
|
|
41
|
+
"HttpException",
|
|
42
|
+
"Container",
|
|
43
|
+
"BindingResolutionError",
|
|
44
|
+
"ServiceProvider",
|
|
45
|
+
"Router",
|
|
46
|
+
"Route",
|
|
47
|
+
"ViewEngine",
|
|
48
|
+
"Model",
|
|
49
|
+
"configure_database",
|
|
50
|
+
"Migration",
|
|
51
|
+
"Schema",
|
|
52
|
+
"Blueprint",
|
|
53
|
+
"Auth",
|
|
54
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Caprail Application — the heart of the framework.
|
|
3
|
+
|
|
4
|
+
Bootstraps the IoC container, registers service providers,
|
|
5
|
+
and serves as the ASGI entry point.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from caprail.foundation.container import Container
|
|
16
|
+
from caprail.foundation.service_provider import ServiceProvider
|
|
17
|
+
from caprail.http.request import Request
|
|
18
|
+
from caprail.http.response import Response
|
|
19
|
+
from caprail.routing.router import Router
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Application(Container):
|
|
23
|
+
"""
|
|
24
|
+
The Caprail application.
|
|
25
|
+
|
|
26
|
+
Extends the service container and manages the full request lifecycle.
|
|
27
|
+
|
|
28
|
+
Usage (in ``bootstrap/app.py``)::
|
|
29
|
+
|
|
30
|
+
from caprail import Application
|
|
31
|
+
|
|
32
|
+
app = Application(base_path=Path(__file__).parent.parent)
|
|
33
|
+
app.register_providers([AppServiceProvider, RouteServiceProvider])
|
|
34
|
+
application = app.make_asgi()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
VERSION = "0.1.0"
|
|
38
|
+
|
|
39
|
+
def __init__(self, base_path: Path | str | None = None) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.base_path = Path(base_path) if base_path else Path.cwd()
|
|
42
|
+
self._providers: list[ServiceProvider] = []
|
|
43
|
+
self._booted = False
|
|
44
|
+
self._router = Router()
|
|
45
|
+
self._global_middleware: list[type] = []
|
|
46
|
+
self._env: dict[str, str] = {}
|
|
47
|
+
|
|
48
|
+
self.instance("app", self)
|
|
49
|
+
self.instance(Application, self)
|
|
50
|
+
self.instance("router", self._router)
|
|
51
|
+
self.instance(Router, self._router)
|
|
52
|
+
|
|
53
|
+
self._load_env()
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Paths
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def path(self, *parts: str) -> Path:
|
|
60
|
+
return self.base_path.joinpath(*parts)
|
|
61
|
+
|
|
62
|
+
def app_path(self, *parts: str) -> Path:
|
|
63
|
+
return self.base_path / "app" / Path(*parts) if parts else self.base_path / "app"
|
|
64
|
+
|
|
65
|
+
def config_path(self, *parts: str) -> Path:
|
|
66
|
+
return self.base_path / "config" / Path(*parts) if parts else self.base_path / "config"
|
|
67
|
+
|
|
68
|
+
def database_path(self, *parts: str) -> Path:
|
|
69
|
+
return self.base_path / "database" / Path(*parts) if parts else self.base_path / "database"
|
|
70
|
+
|
|
71
|
+
def resources_path(self, *parts: str) -> Path:
|
|
72
|
+
return self.base_path / "resources" / Path(*parts) if parts else self.base_path / "resources"
|
|
73
|
+
|
|
74
|
+
def storage_path(self, *parts: str) -> Path:
|
|
75
|
+
return self.base_path / "storage" / Path(*parts) if parts else self.base_path / "storage"
|
|
76
|
+
|
|
77
|
+
def public_path(self, *parts: str) -> Path:
|
|
78
|
+
return self.base_path / "public" / Path(*parts) if parts else self.base_path / "public"
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Environment
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _load_env(self) -> None:
|
|
85
|
+
env_file = self.base_path / ".env"
|
|
86
|
+
if env_file.exists():
|
|
87
|
+
from dotenv import dotenv_values
|
|
88
|
+
self._env = dict(dotenv_values(str(env_file)))
|
|
89
|
+
os.environ.update({k: v for k, v in self._env.items() if v is not None})
|
|
90
|
+
|
|
91
|
+
def env(self, key: str, default: Any = None) -> Any:
|
|
92
|
+
return os.environ.get(key, self._env.get(key, default))
|
|
93
|
+
|
|
94
|
+
def is_production(self) -> bool:
|
|
95
|
+
return self.env("APP_ENV", "production") == "production"
|
|
96
|
+
|
|
97
|
+
def is_debug(self) -> bool:
|
|
98
|
+
return str(self.env("APP_DEBUG", "false")).lower() in ("true", "1", "yes")
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Providers
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def register_providers(self, providers: list[type[ServiceProvider]]) -> None:
|
|
105
|
+
for cls in providers:
|
|
106
|
+
provider = cls(self)
|
|
107
|
+
provider.register()
|
|
108
|
+
self._providers.append(provider)
|
|
109
|
+
|
|
110
|
+
def boot(self) -> None:
|
|
111
|
+
if self._booted:
|
|
112
|
+
return
|
|
113
|
+
for provider in self._providers:
|
|
114
|
+
provider.boot()
|
|
115
|
+
self._booted = True
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Routing helpers
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def router(self) -> Router:
|
|
123
|
+
return self._router
|
|
124
|
+
|
|
125
|
+
def add_middleware(self, *middleware_classes: type) -> None:
|
|
126
|
+
self._global_middleware.extend(middleware_classes)
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# ASGI interface
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def make_asgi(self) -> Callable:
|
|
133
|
+
self.boot()
|
|
134
|
+
return self._asgi_handler
|
|
135
|
+
|
|
136
|
+
async def _asgi_handler(self, scope: dict, receive, send) -> None:
|
|
137
|
+
if scope["type"] == "lifespan":
|
|
138
|
+
await self._handle_lifespan(receive, send)
|
|
139
|
+
return
|
|
140
|
+
if scope["type"] != "http":
|
|
141
|
+
return
|
|
142
|
+
request = Request(scope, receive, send)
|
|
143
|
+
response = await self._dispatch(request)
|
|
144
|
+
if response._headers.pop("_back", None):
|
|
145
|
+
url = request.header("referer", "/")
|
|
146
|
+
response._headers["location"] = url
|
|
147
|
+
await response.send(send)
|
|
148
|
+
|
|
149
|
+
async def _dispatch(self, request: Request) -> Response:
|
|
150
|
+
from caprail.http.middleware import Pipeline
|
|
151
|
+
|
|
152
|
+
result = self._router.resolve(request.method, request.path)
|
|
153
|
+
if result is None:
|
|
154
|
+
return self._not_found(request)
|
|
155
|
+
|
|
156
|
+
route, params = result
|
|
157
|
+
request.route_params = params
|
|
158
|
+
request.route_name = route.name
|
|
159
|
+
|
|
160
|
+
route_mw_classes = [
|
|
161
|
+
self._router.middleware_aliases.get(name)
|
|
162
|
+
for name in route.middleware
|
|
163
|
+
if name in self._router.middleware_aliases
|
|
164
|
+
]
|
|
165
|
+
all_classes = self._global_middleware + [c for c in route_mw_classes if c]
|
|
166
|
+
middleware_instances = self._resolve_middleware(all_classes)
|
|
167
|
+
|
|
168
|
+
async def call_handler(req: Request) -> Response:
|
|
169
|
+
return await self._call_handler(route.handler, req)
|
|
170
|
+
|
|
171
|
+
return await Pipeline(middleware_instances).send(request).then(call_handler)
|
|
172
|
+
|
|
173
|
+
def _resolve_middleware(self, classes: list[type]) -> list:
|
|
174
|
+
instances = []
|
|
175
|
+
for cls in classes:
|
|
176
|
+
try:
|
|
177
|
+
instances.append(self.make(cls))
|
|
178
|
+
except Exception:
|
|
179
|
+
instances.append(cls())
|
|
180
|
+
return instances
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
async def _call_handler(handler: Callable, request: Request) -> Response:
|
|
184
|
+
result = await handler(request) if inspect.iscoroutinefunction(handler) else handler(request)
|
|
185
|
+
if isinstance(result, Response):
|
|
186
|
+
return result
|
|
187
|
+
if isinstance(result, str):
|
|
188
|
+
return Response(result)
|
|
189
|
+
if isinstance(result, dict):
|
|
190
|
+
return Response.json(result)
|
|
191
|
+
return Response(str(result) if result is not None else "")
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _not_found(request: Request) -> Response:
|
|
195
|
+
if request.wants_json():
|
|
196
|
+
return Response.json({"message": "Not found."}, status=404)
|
|
197
|
+
return Response("<h1>404 — Not Found</h1>", status=404)
|
|
198
|
+
|
|
199
|
+
async def _handle_lifespan(self, receive, send) -> None:
|
|
200
|
+
while True:
|
|
201
|
+
message = await receive()
|
|
202
|
+
if message["type"] == "lifespan.startup":
|
|
203
|
+
await send({"type": "lifespan.startup.complete"})
|
|
204
|
+
elif message["type"] == "lifespan.shutdown":
|
|
205
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
206
|
+
return
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Caprail Auth Manager.
|
|
3
|
+
|
|
4
|
+
Session and token-based authentication manager.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
# In a controller:
|
|
9
|
+
success = await Auth.attempt(request, email="user@example.com", password="secret")
|
|
10
|
+
user = Auth.user(request)
|
|
11
|
+
Auth.logout(request)
|
|
12
|
+
is_logged_in = Auth.check(request)
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
from typing import Any, TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from caprail.http.request import Request
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Auth:
|
|
25
|
+
"""Static authentication helper."""
|
|
26
|
+
|
|
27
|
+
_user_model: type | None = None
|
|
28
|
+
_password_field: str = "password"
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def configure(cls, user_model: type, password_field: str = "password") -> None:
|
|
32
|
+
cls._user_model = user_model
|
|
33
|
+
cls._password_field = password_field
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
async def attempt(cls, request: "Request", **credentials) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Attempt to authenticate with the given credentials.
|
|
39
|
+
|
|
40
|
+
Stores the user in the session on success.
|
|
41
|
+
"""
|
|
42
|
+
if cls._user_model is None:
|
|
43
|
+
raise RuntimeError("Auth not configured. Call Auth.configure(UserModel).")
|
|
44
|
+
|
|
45
|
+
password = credentials.pop("password", None)
|
|
46
|
+
query = cls._user_model.query()
|
|
47
|
+
for field, value in credentials.items():
|
|
48
|
+
query = query.where(field, value)
|
|
49
|
+
user = await query.first()
|
|
50
|
+
|
|
51
|
+
if user is None:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
stored = getattr(user, cls._password_field, None)
|
|
55
|
+
if not stored or not cls._check_password(password or "", stored):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
request.session["_auth_id"] = getattr(user, user.primary_key)
|
|
59
|
+
request.user = user
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
async def login(cls, request: "Request", user: Any) -> None:
|
|
64
|
+
"""Log in the given user directly (no credential check)."""
|
|
65
|
+
request.session["_auth_id"] = getattr(user, user.primary_key)
|
|
66
|
+
request.user = user
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def logout(cls, request: "Request") -> None:
|
|
70
|
+
request.session.pop("_auth_id", None)
|
|
71
|
+
request.user = None
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def check(cls, request: "Request") -> bool:
|
|
75
|
+
return "_auth_id" in request.session or request.user is not None
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def user(cls, request: "Request") -> Any | None:
|
|
79
|
+
return request.user
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def id(cls, request: "Request") -> Any | None:
|
|
83
|
+
return request.session.get("_auth_id")
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Password hashing
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def hash_password(password: str) -> str:
|
|
91
|
+
import hashlib, os
|
|
92
|
+
salt = os.urandom(16).hex()
|
|
93
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 260000).hex()
|
|
94
|
+
return f"pbkdf2:sha256:260000:{salt}:{digest}"
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _check_password(plain: str, hashed: str) -> bool:
|
|
98
|
+
if hashed.startswith("pbkdf2:sha256:"):
|
|
99
|
+
parts = hashed.split(":")
|
|
100
|
+
_, _, iterations, salt, stored_digest = parts
|
|
101
|
+
digest = hashlib.pbkdf2_hmac(
|
|
102
|
+
"sha256", plain.encode(), salt.encode(), int(iterations)
|
|
103
|
+
).hex()
|
|
104
|
+
return hmac.compare_digest(digest, stored_digest)
|
|
105
|
+
# Fallback plain text (dev only)
|
|
106
|
+
return plain == hashed
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from caprail.console.commands import (
|
|
2
|
+
make_controller,
|
|
3
|
+
make_model,
|
|
4
|
+
make_migration,
|
|
5
|
+
make_middleware,
|
|
6
|
+
make_provider,
|
|
7
|
+
make_request,
|
|
8
|
+
migrate_cmd,
|
|
9
|
+
migrate_rollback,
|
|
10
|
+
migrate_fresh,
|
|
11
|
+
migrate_status,
|
|
12
|
+
serve_cmd,
|
|
13
|
+
route_list,
|
|
14
|
+
key_generate,
|
|
15
|
+
new_project,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"make_controller",
|
|
20
|
+
"make_model",
|
|
21
|
+
"make_migration",
|
|
22
|
+
"make_middleware",
|
|
23
|
+
"make_provider",
|
|
24
|
+
"make_request",
|
|
25
|
+
"migrate_cmd",
|
|
26
|
+
"migrate_rollback",
|
|
27
|
+
"migrate_fresh",
|
|
28
|
+
"migrate_status",
|
|
29
|
+
"serve_cmd",
|
|
30
|
+
"route_list",
|
|
31
|
+
"key_generate",
|
|
32
|
+
"new_project",
|
|
33
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""caprail key:generate"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
def command(
|
|
12
|
+
show: bool = typer.Option(False, "--show", help="Print the key instead of writing to .env"),
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Generate a new application key."""
|
|
15
|
+
key = "base64:" + secrets.token_urlsafe(32)
|
|
16
|
+
if show:
|
|
17
|
+
console.print(f"[cyan]{key}[/]")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
env_file = Path.cwd() / ".env"
|
|
21
|
+
if env_file.exists():
|
|
22
|
+
content = env_file.read_text()
|
|
23
|
+
if "APP_KEY=" in content:
|
|
24
|
+
import re
|
|
25
|
+
content = re.sub(r"APP_KEY=.*", f"APP_KEY={key}", content)
|
|
26
|
+
else:
|
|
27
|
+
content += f"\nAPP_KEY={key}\n"
|
|
28
|
+
env_file.write_text(content)
|
|
29
|
+
console.print(f"[green]Application key set:[/] {key}")
|
|
30
|
+
else:
|
|
31
|
+
console.print(f"[yellow].env not found. Key:[/] {key}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""caprail make controller <Name>"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
TEMPLATE = '''"""
|
|
10
|
+
{name} controller.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from caprail.http.request import Request
|
|
15
|
+
from caprail.http.response import Response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class {name}:
|
|
19
|
+
|
|
20
|
+
async def index(self, request: Request) -> Response:
|
|
21
|
+
return Response.json({{"message": "Hello from {name}.index"}})
|
|
22
|
+
|
|
23
|
+
async def create(self, request: Request) -> Response:
|
|
24
|
+
return Response("Create form")
|
|
25
|
+
|
|
26
|
+
async def store(self, request: Request) -> Response:
|
|
27
|
+
data = await request.all()
|
|
28
|
+
return Response.json(data, status=201)
|
|
29
|
+
|
|
30
|
+
async def show(self, request: Request) -> Response:
|
|
31
|
+
return Response.json({{"id": request.route("id")}})
|
|
32
|
+
|
|
33
|
+
async def edit(self, request: Request) -> Response:
|
|
34
|
+
return Response("Edit form")
|
|
35
|
+
|
|
36
|
+
async def update(self, request: Request) -> Response:
|
|
37
|
+
data = await request.all()
|
|
38
|
+
return Response.json(data)
|
|
39
|
+
|
|
40
|
+
async def destroy(self, request: Request) -> Response:
|
|
41
|
+
return Response.no_content()
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
RESOURCE_TEMPLATE = TEMPLATE # same for now
|
|
45
|
+
|
|
46
|
+
def command(
|
|
47
|
+
name: str = typer.Argument(..., help="Controller class name"),
|
|
48
|
+
resource: bool = typer.Option(False, "--resource", "-r", help="Generate a resource controller"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Generate a new HTTP controller."""
|
|
51
|
+
if not name.endswith("Controller"):
|
|
52
|
+
name = name + "Controller"
|
|
53
|
+
dest = Path.cwd() / "app" / "Http" / "Controllers" / f"{name}.py"
|
|
54
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
if dest.exists():
|
|
56
|
+
console.print(f"[yellow]Controller already exists:[/] {dest}")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
tpl = RESOURCE_TEMPLATE if resource else TEMPLATE
|
|
59
|
+
dest.write_text(tpl.format(name=name))
|
|
60
|
+
console.print(f"[green]Controller created:[/] {dest}")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""caprail make middleware <Name>"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
TEMPLATE = '''"""
|
|
10
|
+
{name} middleware.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from caprail.http.middleware import Middleware, NextCallable
|
|
15
|
+
from caprail.http.request import Request
|
|
16
|
+
from caprail.http.response import Response
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class {name}(Middleware):
|
|
20
|
+
|
|
21
|
+
async def handle(self, request: Request, next: NextCallable) -> Response:
|
|
22
|
+
# Add your middleware logic here
|
|
23
|
+
response = await next(request)
|
|
24
|
+
return response
|
|
25
|
+
'''
|
|
26
|
+
|
|
27
|
+
def command(name: str = typer.Argument(..., help="Middleware class name")) -> None:
|
|
28
|
+
"""Generate a new middleware class."""
|
|
29
|
+
if not name.endswith("Middleware"):
|
|
30
|
+
name += "Middleware"
|
|
31
|
+
dest = Path.cwd() / "app" / "Http" / "Middleware" / f"{name}.py"
|
|
32
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
if dest.exists():
|
|
34
|
+
console.print(f"[yellow]Middleware already exists:[/] {dest}")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
dest.write_text(TEMPLATE.format(name=name))
|
|
37
|
+
console.print(f"[green]Middleware created:[/] {dest}")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""caprail make migration <name>"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
TEMPLATE = '''"""
|
|
11
|
+
{class_name} migration.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from caprail.orm.migrations import Migration, Schema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class {class_name}(Migration):
|
|
19
|
+
|
|
20
|
+
async def up(self, schema: Schema) -> None:
|
|
21
|
+
await schema.create("{table}", lambda table: [
|
|
22
|
+
table.id(),
|
|
23
|
+
table.timestamps(),
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
async def down(self, schema: Schema) -> None:
|
|
27
|
+
await schema.drop_if_exists("{table}")
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
def _class_name(name: str) -> str:
|
|
31
|
+
return "".join(part.capitalize() for part in name.split("_"))
|
|
32
|
+
|
|
33
|
+
def _table_from_name(name: str) -> str:
|
|
34
|
+
name = name.removeprefix("create_").removesuffix("_table")
|
|
35
|
+
return name
|
|
36
|
+
|
|
37
|
+
def _create(name: str) -> None:
|
|
38
|
+
ts = datetime.utcnow().strftime("%Y_%m_%d_%H%M%S")
|
|
39
|
+
filename = f"{ts}_{name}.py"
|
|
40
|
+
dest = Path.cwd() / "database" / "migrations" / filename
|
|
41
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
cls = _class_name(name)
|
|
43
|
+
table = _table_from_name(name)
|
|
44
|
+
dest.write_text(TEMPLATE.format(class_name=cls, table=table))
|
|
45
|
+
console.print(f"[green]Migration created:[/] {dest}")
|
|
46
|
+
|
|
47
|
+
def command(
|
|
48
|
+
name: str = typer.Argument(..., help="Migration name (snake_case)"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Generate a new database migration."""
|
|
51
|
+
_create(name)
|