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.
Files changed (48) hide show
  1. caprail-0.1.0/PKG-INFO +36 -0
  2. caprail-0.1.0/README.md +0 -0
  3. caprail-0.1.0/pyproject.toml +58 -0
  4. caprail-0.1.0/src/caprail/__init__.py +54 -0
  5. caprail-0.1.0/src/caprail/application.py +206 -0
  6. caprail-0.1.0/src/caprail/auth/__init__.py +3 -0
  7. caprail-0.1.0/src/caprail/auth/auth_manager.py +106 -0
  8. caprail-0.1.0/src/caprail/console/__init__.py +0 -0
  9. caprail-0.1.0/src/caprail/console/commands/__init__.py +33 -0
  10. caprail-0.1.0/src/caprail/console/commands/key_generate.py +31 -0
  11. caprail-0.1.0/src/caprail/console/commands/make_controller.py +60 -0
  12. caprail-0.1.0/src/caprail/console/commands/make_middleware.py +37 -0
  13. caprail-0.1.0/src/caprail/console/commands/make_migration.py +51 -0
  14. caprail-0.1.0/src/caprail/console/commands/make_model.py +43 -0
  15. caprail-0.1.0/src/caprail/console/commands/make_provider.py +36 -0
  16. caprail-0.1.0/src/caprail/console/commands/make_request.py +38 -0
  17. caprail-0.1.0/src/caprail/console/commands/migrate_cmd.py +44 -0
  18. caprail-0.1.0/src/caprail/console/commands/migrate_fresh.py +24 -0
  19. caprail-0.1.0/src/caprail/console/commands/migrate_rollback.py +26 -0
  20. caprail-0.1.0/src/caprail/console/commands/migrate_status.py +31 -0
  21. caprail-0.1.0/src/caprail/console/commands/new_project.py +629 -0
  22. caprail-0.1.0/src/caprail/console/commands/route_list.py +37 -0
  23. caprail-0.1.0/src/caprail/console/commands/serve_cmd.py +53 -0
  24. caprail-0.1.0/src/caprail/console/kernel.py +69 -0
  25. caprail-0.1.0/src/caprail/foundation/__init__.py +4 -0
  26. caprail-0.1.0/src/caprail/foundation/container.py +169 -0
  27. caprail-0.1.0/src/caprail/foundation/service_provider.py +59 -0
  28. caprail-0.1.0/src/caprail/http/__init__.py +14 -0
  29. caprail-0.1.0/src/caprail/http/exceptions.py +9 -0
  30. caprail-0.1.0/src/caprail/http/form_request.py +82 -0
  31. caprail-0.1.0/src/caprail/http/middleware.py +70 -0
  32. caprail-0.1.0/src/caprail/http/middleware_builtin.py +132 -0
  33. caprail-0.1.0/src/caprail/http/request.py +277 -0
  34. caprail-0.1.0/src/caprail/http/response.py +229 -0
  35. caprail-0.1.0/src/caprail/http/uploaded_file.py +30 -0
  36. caprail-0.1.0/src/caprail/orm/__init__.py +16 -0
  37. caprail-0.1.0/src/caprail/orm/migrations.py +406 -0
  38. caprail-0.1.0/src/caprail/orm/model.py +312 -0
  39. caprail-0.1.0/src/caprail/orm/query_builder.py +292 -0
  40. caprail-0.1.0/src/caprail/orm/relations/__init__.py +0 -0
  41. caprail-0.1.0/src/caprail/orm/relations.py +193 -0
  42. caprail-0.1.0/src/caprail/py.typed +0 -0
  43. caprail-0.1.0/src/caprail/routing/__init__.py +3 -0
  44. caprail-0.1.0/src/caprail/routing/router.py +238 -0
  45. caprail-0.1.0/src/caprail/support/__init__.py +3 -0
  46. caprail-0.1.0/src/caprail/support/helpers.py +82 -0
  47. caprail-0.1.0/src/caprail/view/__init__.py +3 -0
  48. 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
+
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,3 @@
1
+ from caprail.auth.auth_manager import Auth
2
+
3
+ __all__ = ["Auth"]
@@ -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)