future-framework 1.1.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.
- future/__init__.py +3 -0
- future/application.py +656 -0
- future/authentication/Auth0Authentication.py +9 -0
- future/authentication/Authentication.py +11 -0
- future/authentication/AzureADAuthentication.py +9 -0
- future/authentication/BasicAuthentication.py +9 -0
- future/authentication/KerberosAuthentication.py +9 -0
- future/authentication/KeycloakAuthentication.py +9 -0
- future/authentication/OAuth2Authentication.py +9 -0
- future/authentication/OpenIdConnectAuthentication.py +9 -0
- future/authentication/SAMLAuthentication.py +9 -0
- future/cli/__init__.py +1 -0
- future/cli/main.py +608 -0
- future/cli/stubs.py +114 -0
- future/controllers/__init__.py +4 -0
- future/controllers/base.py +8 -0
- future/controllers/builtins.py +41 -0
- future/controllers/graphql.py +23 -0
- future/controllers/openapi.py +224 -0
- future/databases/Clickhouse.py +176 -0
- future/databases/Connections.py +20 -0
- future/databases/Database.py +66 -0
- future/databases/Elasticsearch.py +146 -0
- future/databases/MongoDB.py +177 -0
- future/databases/MySQL.py +215 -0
- future/databases/Postgres.py +214 -0
- future/databases/Redis.py +146 -0
- future/databases/SQLite.py +219 -0
- future/exceptions.py +27 -0
- future/graphql/__init__.py +1 -0
- future/graphql/schema.py +148 -0
- future/lifespan.py +70 -0
- future/logger.py +53 -0
- future/middleware/Middleware.py +201 -0
- future/middleware/SessionMiddleware.py +77 -0
- future/middleware/__init__.py +21 -0
- future/migrations/Blueprint.py +54 -0
- future/migrations/Column.py +33 -0
- future/migrations/Migration.py +11 -0
- future/migrations/MigrationGenerator.py +137 -0
- future/migrations/Migrator.py +72 -0
- future/migrations/Schema.py +21 -0
- future/models/__init__.py +1 -0
- future/models/model.py +224 -0
- future/openapi.py +233 -0
- future/plugins/ElasticsearchPlugin.py +496 -0
- future/plugins/__init__.py +9 -0
- future/request.py +143 -0
- future/response.py +201 -0
- future/routing.py +342 -0
- future/seeds/SeedGenerator.py +95 -0
- future/seeds/SeedRunner.py +42 -0
- future/seeds/Seeder.py +9 -0
- future/settings.py +94 -0
- future/tasks/__init__.py +14 -0
- future/tasks/scheduler.py +267 -0
- future/testing/__init__.py +1 -0
- future/testing/client.py +135 -0
- future/types.py +47 -0
- future_framework-1.1.0.dist-info/METADATA +68 -0
- future_framework-1.1.0.dist-info/RECORD +64 -0
- future_framework-1.1.0.dist-info/WHEEL +4 -0
- future_framework-1.1.0.dist-info/entry_points.txt +3 -0
- future_framework-1.1.0.dist-info/licenses/LICENSE +21 -0
future/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from future.cli.main import main
|
future/cli/main.py
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Boilerplate templates for `future init`
|
|
10
|
+
APP_ROOT = "app"
|
|
11
|
+
APP_DIRS = ["config", "controllers", "middleware", "models", "plugins", "tasks"]
|
|
12
|
+
PROJECT_FILES = [".env.example", ".gitignore", "LICENSE", "pyproject.toml", "README.md"]
|
|
13
|
+
README_MD = """\
|
|
14
|
+
# Future boilerplate
|
|
15
|
+
|
|
16
|
+
Scaffolded with `future init`.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cp .env.example .env # DB_DATABASE=database → database.sqlite
|
|
20
|
+
poetry install
|
|
21
|
+
poetry run future make:migration ExampleModel
|
|
22
|
+
poetry run future migrate
|
|
23
|
+
poetry run future make:seed ExampleModel
|
|
24
|
+
poetry run future seed
|
|
25
|
+
poetry run python run.py
|
|
26
|
+
poetry run future routes
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
ENV_EXAMPLE = """\
|
|
30
|
+
# Application settings
|
|
31
|
+
APP_NAME=Example
|
|
32
|
+
APP_VERSION=1.0
|
|
33
|
+
APP_DESCRIPTION=Description
|
|
34
|
+
APP_DOMAIN=example.com
|
|
35
|
+
APP_HOST=127.0.0.1
|
|
36
|
+
APP_PORT=8000
|
|
37
|
+
APP_DEBUG=True
|
|
38
|
+
APP_ACCESS_LOG=True
|
|
39
|
+
APP_WORKERS=1
|
|
40
|
+
APP_KEY=REPLACE_WITH_GENERATED_SECRET_KEY
|
|
41
|
+
|
|
42
|
+
# Database settings (SQLite by default — no server required)
|
|
43
|
+
DB_DATABASE=database
|
|
44
|
+
|
|
45
|
+
# Optional MySQL (switch app/config/Database.py to MySQL and set these)
|
|
46
|
+
# DB_HOST=127.0.0.1
|
|
47
|
+
# DB_PORT=3306
|
|
48
|
+
# DB_DATABASE=future
|
|
49
|
+
# DB_USERNAME=root
|
|
50
|
+
# DB_PASSWORD=
|
|
51
|
+
"""
|
|
52
|
+
PYPROJECT_TOML = """\
|
|
53
|
+
[tool.poetry]
|
|
54
|
+
name = "future-boilerplate"
|
|
55
|
+
version = "0.0.1"
|
|
56
|
+
description = "Boilerplate app for the Future framework."
|
|
57
|
+
authors = ["nicolaipre"]
|
|
58
|
+
readme = "README.md"
|
|
59
|
+
|
|
60
|
+
[tool.poetry.dependencies]
|
|
61
|
+
python = "^3.12"
|
|
62
|
+
python-dotenv = "^1.0.1"
|
|
63
|
+
future-framework = { git = "https://github.com/nicolaipre/future.git", rev = "master" }
|
|
64
|
+
# Or from PyPI after publish: future-framework = "^1.1.0"
|
|
65
|
+
# Or, while developing Future next to this app:
|
|
66
|
+
# future-framework = { path = "../future", develop = true }
|
|
67
|
+
|
|
68
|
+
[build-system]
|
|
69
|
+
requires = ["poetry-core"]
|
|
70
|
+
build-backend = "poetry.core.masonry.api"
|
|
71
|
+
"""
|
|
72
|
+
LICENSE = """\
|
|
73
|
+
MIT License
|
|
74
|
+
|
|
75
|
+
Copyright (c) 2025 nicolaipre
|
|
76
|
+
|
|
77
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
78
|
+
of this software and associated documentation files (the \"Software\"), to deal
|
|
79
|
+
in the Software without restriction, including without limitation the rights
|
|
80
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
81
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
82
|
+
furnished to do so, subject to the following conditions:
|
|
83
|
+
|
|
84
|
+
The above copyright notice and this permission notice shall be included in all
|
|
85
|
+
copies or substantial portions of the Software.
|
|
86
|
+
|
|
87
|
+
THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
88
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
89
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
90
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
91
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
92
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
93
|
+
SOFTWARE.
|
|
94
|
+
"""
|
|
95
|
+
GITIGNORE = ".idea/\n.vscode/\n.env\n.venv\npoetry.lock\n__pycache__/\n*.sqlite\n*.sqlite3\n"
|
|
96
|
+
|
|
97
|
+
APP_ROUTES = """\
|
|
98
|
+
from app.controllers.ExampleController import ExampleController
|
|
99
|
+
from app.middleware.ExampleMiddleware import ExampleMiddleware
|
|
100
|
+
from future.openapi import openapi_routes
|
|
101
|
+
from future.routing import RouteGroup, Get
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
routes = [
|
|
105
|
+
RouteGroup(
|
|
106
|
+
name="Docs",
|
|
107
|
+
prefix="/api",
|
|
108
|
+
# middlewares=[ExampleMiddleware], # protect docs if needed
|
|
109
|
+
routes=openapi_routes(uis=["swagger", "redoc", "scalar", "rapidoc"]),
|
|
110
|
+
),
|
|
111
|
+
RouteGroup(
|
|
112
|
+
name="Main",
|
|
113
|
+
middlewares=[ExampleMiddleware],
|
|
114
|
+
routes=[
|
|
115
|
+
Get("/", ExampleController.index, "home"),
|
|
116
|
+
Get("/examples", ExampleController.index, "examples"),
|
|
117
|
+
Get("/examples/<id>", ExampleController.show, "example"),
|
|
118
|
+
],
|
|
119
|
+
),
|
|
120
|
+
]
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
EXAMPLE_PLUGIN = """\
|
|
124
|
+
from future.plugins import Plugin
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ExamplePlugin(Plugin):
|
|
128
|
+
pass
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
EXAMPLE_MODEL = """\
|
|
132
|
+
from future.models import Model
|
|
133
|
+
from datetime import datetime
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ExampleModel(Model):
|
|
137
|
+
__connection__ = "default"
|
|
138
|
+
# __table__ = "example_models" # optional; otherwise tableized class name
|
|
139
|
+
|
|
140
|
+
id: str
|
|
141
|
+
name: str
|
|
142
|
+
description: str
|
|
143
|
+
created_at: datetime
|
|
144
|
+
updated_at: datetime
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
EXAMPLE_MIDDLEWARE = """\
|
|
148
|
+
from future.middleware import Middleware
|
|
149
|
+
from future.response import Response
|
|
150
|
+
from typing import Optional
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ExampleMiddleware(Middleware):
|
|
154
|
+
name = "ExampleMiddleware"
|
|
155
|
+
priority = 0
|
|
156
|
+
|
|
157
|
+
async def before(self) -> Optional[Response]:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
async def after(self) -> Optional[Response]:
|
|
161
|
+
return None
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
EXAMPLE_CONTROLLER = """\
|
|
165
|
+
from future.controllers import Controller
|
|
166
|
+
from future.response import Response
|
|
167
|
+
from app.models.ExampleModel import ExampleModel
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ExampleController(Controller):
|
|
171
|
+
async def index(self) -> Response:
|
|
172
|
+
examples = ExampleModel.all()
|
|
173
|
+
return self.response.json([example.to_dict() for example in examples], status=200)
|
|
174
|
+
|
|
175
|
+
async def show(self, id: str) -> Response:
|
|
176
|
+
example = ExampleModel.find(id)
|
|
177
|
+
if example is None:
|
|
178
|
+
return self.response.json({"error": "Not found"}, status=404)
|
|
179
|
+
return self.response.json(example.to_dict(), status=200)
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
EXAMPLE_TASK = """\
|
|
183
|
+
\"\"\"
|
|
184
|
+
Example task — pass to Lifespan cron_tasks via Task(..., func=run).
|
|
185
|
+
\"\"\"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run():
|
|
189
|
+
pass
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
CONFIG_DATABASE = """\
|
|
193
|
+
from future.databases.SQLite import SQLite
|
|
194
|
+
from app.config.Settings import DB_DATABASE
|
|
195
|
+
|
|
196
|
+
# Default driver: SQLite (local file). Swap in MySQL when you need a server.
|
|
197
|
+
# Future(config={"DATABASES": DATABASES}) registers Connections at boot.
|
|
198
|
+
DATABASES = {
|
|
199
|
+
"default": "sqlite",
|
|
200
|
+
"sqlite": SQLite(database=DB_DATABASE),
|
|
201
|
+
}
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
CONFIG_SETTINGS = """\
|
|
205
|
+
from os import environ as env
|
|
206
|
+
from dotenv import load_dotenv
|
|
207
|
+
|
|
208
|
+
load_dotenv(dotenv_path=".env")
|
|
209
|
+
|
|
210
|
+
# Application settings sourced from .env
|
|
211
|
+
APP_NAME = str(env.get("APP_NAME", "Future"))
|
|
212
|
+
APP_VERSION = str(env.get("APP_VERSION", "1.0"))
|
|
213
|
+
APP_DESCRIPTION = str(env.get("APP_DESCRIPTION", "A short description"))
|
|
214
|
+
APP_DEBUG = env.get("APP_DEBUG", "False").lower() == "true"
|
|
215
|
+
APP_LOG_LEVEL = str("DEBUG" if APP_DEBUG else env.get("APP_LOG_LEVEL", "INFO"))
|
|
216
|
+
APP_ACCESS_LOG = env.get("APP_ACCESS_LOG", "False").lower() == "true"
|
|
217
|
+
APP_WORKERS = int(env.get("APP_WORKERS", 1))
|
|
218
|
+
APP_HOST = str(env.get("APP_HOST", "127.0.0.1"))
|
|
219
|
+
APP_PORT = int(env.get("APP_PORT", 8000))
|
|
220
|
+
APP_KEY = str(env.get("APP_KEY", "secret"))
|
|
221
|
+
APP_DOMAIN = str(env.get("APP_DOMAIN", "example.com"))
|
|
222
|
+
|
|
223
|
+
# Database settings sourced from .env (name only; SQLite appends .sqlite)
|
|
224
|
+
DB_DATABASE = str(env.get("DB_DATABASE", "database"))
|
|
225
|
+
DB_HOST = str(env.get("DB_HOST", "127.0.0.1"))
|
|
226
|
+
DB_PORT = int(env.get("DB_PORT", 3306))
|
|
227
|
+
DB_USERNAME = str(env.get("DB_USERNAME", "root"))
|
|
228
|
+
DB_PASSWORD = str(env.get("DB_PASSWORD", ""))
|
|
229
|
+
DB_LOGGING = env.get("DB_LOGGING", "False").lower() == "true"
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
RUN_PY = """\
|
|
233
|
+
from app.config.Settings import APP_HOST, APP_PORT, APP_DEBUG, APP_WORKERS, APP_NAME, APP_DOMAIN
|
|
234
|
+
from app.config.Database import DATABASES
|
|
235
|
+
from app.routes import routes
|
|
236
|
+
from future.application import Future
|
|
237
|
+
from future.lifespan import Lifespan
|
|
238
|
+
|
|
239
|
+
# Tasks that will run on startup:
|
|
240
|
+
startup_tasks = [
|
|
241
|
+
# Task()
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
# Tasks that will run on shutdown:
|
|
245
|
+
shutdown_tasks = [
|
|
246
|
+
# Task()
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
# Tasks that will run with intervals using a cron-like scheduler:
|
|
250
|
+
cron_tasks = [
|
|
251
|
+
# Task("Example", interval=1, unit=Unit.HOURS, func=run),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
config = {
|
|
255
|
+
"APP_DOMAIN": APP_DOMAIN,
|
|
256
|
+
"APP_NAME": APP_NAME,
|
|
257
|
+
"APP_HOST": APP_HOST,
|
|
258
|
+
"APP_PORT": APP_PORT,
|
|
259
|
+
"APP_DEBUG": APP_DEBUG,
|
|
260
|
+
"DATABASES": DATABASES,
|
|
261
|
+
"OPENAPI": {
|
|
262
|
+
"enabled": True,
|
|
263
|
+
"uis": ["swagger", "redoc", "scalar", "rapidoc"],
|
|
264
|
+
"auto_routes": False,
|
|
265
|
+
"path_prefix": "",
|
|
266
|
+
# "redocly_license_key": "", # paid Reference Docs (Try it); omit for OSS ReDoc
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lifespan = Lifespan(startup_tasks=startup_tasks, shutdown_tasks=shutdown_tasks, cron_tasks=cron_tasks)
|
|
271
|
+
app = Future(lifespan=lifespan, config=config)
|
|
272
|
+
app.add_routes(routes)
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
app.run(host=APP_HOST, port=APP_PORT, workers=APP_WORKERS)
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_app_from_project() -> Any:
|
|
280
|
+
"""Dynamically import the app from the current project."""
|
|
281
|
+
import importlib.util
|
|
282
|
+
|
|
283
|
+
# Add current directory to Python path for scaffolded projects
|
|
284
|
+
if os.getcwd() not in sys.path:
|
|
285
|
+
sys.path.insert(0, os.getcwd())
|
|
286
|
+
|
|
287
|
+
# Try to import from run.py
|
|
288
|
+
if os.path.exists("run.py"):
|
|
289
|
+
spec = importlib.util.spec_from_file_location("run", "run.py")
|
|
290
|
+
if spec and spec.loader:
|
|
291
|
+
module = importlib.util.module_from_spec(spec)
|
|
292
|
+
spec.loader.exec_module(module)
|
|
293
|
+
if hasattr(module, "app"):
|
|
294
|
+
return module.app
|
|
295
|
+
|
|
296
|
+
print("Error: Could not find app instance. Make sure you're in a Future project directory.")
|
|
297
|
+
print("Expected to find app in: run.py")
|
|
298
|
+
sys.exit(1)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def print_routes() -> None:
|
|
302
|
+
from rich.box import SIMPLE_HEAD
|
|
303
|
+
from rich.console import Console
|
|
304
|
+
from rich.table import Table
|
|
305
|
+
from rich.text import Text
|
|
306
|
+
|
|
307
|
+
console = Console()
|
|
308
|
+
app = get_app_from_project()
|
|
309
|
+
routes = getattr(app, "routes", {})
|
|
310
|
+
if not routes:
|
|
311
|
+
console.print("[yellow]No routes registered.[/yellow]")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
# (domain, group_name, prefix, subdomain, middleware_label) -> rows
|
|
315
|
+
sections: dict[tuple[str, str, str, str, str], list[tuple[str, str, str, str]]] = {}
|
|
316
|
+
for domain, domain_routes in routes.items():
|
|
317
|
+
domain_label = domain or "(any host)"
|
|
318
|
+
for route_key, route_info in domain_routes.items():
|
|
319
|
+
route = route_info.get("route")
|
|
320
|
+
path = getattr(route, "path", None) or (route_key.split(" ", 1)[-1] if " " in str(route_key) else route_key)
|
|
321
|
+
methods = route_info.get("methods") or (["WEBSOCKET"] if "ws" in path else ["GET"])
|
|
322
|
+
name = getattr(route, "name", "") or ""
|
|
323
|
+
group = route_info.get("group") or {}
|
|
324
|
+
group_name = group.get("name") or "(ungrouped)"
|
|
325
|
+
prefix = group.get("prefix") or ""
|
|
326
|
+
group_sub = group.get("subdomain") or ""
|
|
327
|
+
middleware_classes = route_info.get("middleware", {}).get("classes", [])
|
|
328
|
+
middleware_names = []
|
|
329
|
+
for middleware in middleware_classes:
|
|
330
|
+
if hasattr(middleware, "name") and middleware.name:
|
|
331
|
+
middleware_names.append(middleware.name)
|
|
332
|
+
else:
|
|
333
|
+
middleware_names.append(middleware.__name__)
|
|
334
|
+
middleware_label = ", ".join(middleware_names) if middleware_names else "(none)"
|
|
335
|
+
param_names = getattr(route, "param_names", None) or []
|
|
336
|
+
args = ", ".join(param_names) if param_names else "-"
|
|
337
|
+
section_key = (domain_label, group_name, prefix, group_sub, middleware_label)
|
|
338
|
+
sections.setdefault(section_key, []).append((",".join(methods), path, name, args))
|
|
339
|
+
|
|
340
|
+
console.print()
|
|
341
|
+
console.print(Text("Routes", style="bold"))
|
|
342
|
+
total = 0
|
|
343
|
+
for (domain_label, group_name, prefix, group_sub, middleware_label), rows in sorted(sections.items(), key=lambda item: (item[0][0], item[0][1], item[0][2])):
|
|
344
|
+
rows.sort(key=lambda row: (row[1], row[0]))
|
|
345
|
+
total += len(rows)
|
|
346
|
+
meta_parts = [f"domain={domain_label}"]
|
|
347
|
+
if prefix:
|
|
348
|
+
meta_parts.append(f"prefix={prefix}")
|
|
349
|
+
if group_sub:
|
|
350
|
+
meta_parts.append(f"subdomain={group_sub}")
|
|
351
|
+
meta_parts.append(f"middleware={middleware_label}")
|
|
352
|
+
console.print()
|
|
353
|
+
console.print(Text(f"{group_name}", style="bold cyan"), Text(f" {' · '.join(meta_parts)}", style="dim"))
|
|
354
|
+
table = Table(box=SIMPLE_HEAD, show_header=True, pad_edge=False, expand=False)
|
|
355
|
+
table.add_column("Method", style="magenta", no_wrap=True)
|
|
356
|
+
table.add_column("Path", style="green")
|
|
357
|
+
table.add_column("Name", style="white")
|
|
358
|
+
table.add_column("Args", style="yellow")
|
|
359
|
+
for method, path, name, args in rows:
|
|
360
|
+
table.add_row(method, path, name, args)
|
|
361
|
+
console.print(table)
|
|
362
|
+
|
|
363
|
+
console.print()
|
|
364
|
+
console.print(Text(f"{total} route(s) in {len(sections)} group(s)", style="dim"))
|
|
365
|
+
console.print()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def scaffold_project(target_dir: str) -> None:
|
|
369
|
+
target = Path(target_dir)
|
|
370
|
+
if target.exists() and any(target.iterdir()):
|
|
371
|
+
print(f"Directory {target} already exists and is not empty.")
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
# Create main app directory
|
|
375
|
+
app_dir = target / APP_ROOT
|
|
376
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
|
|
378
|
+
# Create subdirectories
|
|
379
|
+
for d in APP_DIRS:
|
|
380
|
+
(app_dir / d).mkdir(parents=True, exist_ok=True)
|
|
381
|
+
|
|
382
|
+
# Create root level files
|
|
383
|
+
(target / "README.md").write_text(README_MD)
|
|
384
|
+
(target / ".env.example").write_text(ENV_EXAMPLE)
|
|
385
|
+
(target / "pyproject.toml").write_text(PYPROJECT_TOML)
|
|
386
|
+
(target / "LICENSE").write_text(LICENSE)
|
|
387
|
+
(target / ".gitignore").write_text(GITIGNORE)
|
|
388
|
+
(target / "run.py").write_text(RUN_PY)
|
|
389
|
+
|
|
390
|
+
# Create app-specific files
|
|
391
|
+
(app_dir / "routes.py").write_text(APP_ROUTES)
|
|
392
|
+
(app_dir / "config" / "Settings.py").write_text(CONFIG_SETTINGS)
|
|
393
|
+
(app_dir / "config" / "Database.py").write_text(CONFIG_DATABASE)
|
|
394
|
+
(target / "database" / "migrations").mkdir(parents=True, exist_ok=True)
|
|
395
|
+
(target / "database" / "seeds").mkdir(parents=True, exist_ok=True)
|
|
396
|
+
(app_dir / "controllers" / "ExampleController.py").write_text(EXAMPLE_CONTROLLER)
|
|
397
|
+
(app_dir / "middleware" / "ExampleMiddleware.py").write_text(EXAMPLE_MIDDLEWARE)
|
|
398
|
+
(app_dir / "models" / "ExampleModel.py").write_text(EXAMPLE_MODEL)
|
|
399
|
+
(app_dir / "plugins" / "ExamplePlugin.py").write_text(EXAMPLE_PLUGIN)
|
|
400
|
+
(app_dir / "tasks" / "ExampleTask.py").write_text(EXAMPLE_TASK)
|
|
401
|
+
|
|
402
|
+
print(f"Scaffolded new Future project at {target.resolve()}")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def ensure_project_path() -> None:
|
|
406
|
+
if os.getcwd() not in sys.path:
|
|
407
|
+
sys.path.insert(0, os.getcwd())
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def register_database_connections() -> None:
|
|
411
|
+
get_app_from_project()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def run_migrations(rollback: bool = False) -> None:
|
|
415
|
+
from future.migrations.Migrator import Migrator
|
|
416
|
+
from rich.console import Console
|
|
417
|
+
from rich.text import Text
|
|
418
|
+
|
|
419
|
+
register_database_connections()
|
|
420
|
+
migrator = Migrator(path="database/migrations")
|
|
421
|
+
console = Console()
|
|
422
|
+
if rollback:
|
|
423
|
+
rolled = migrator.rollback()
|
|
424
|
+
if not rolled:
|
|
425
|
+
console.print(Text("✓ Nothing to rollback.", style="green"))
|
|
426
|
+
return
|
|
427
|
+
for name in rolled:
|
|
428
|
+
line = Text()
|
|
429
|
+
line.append("✓ ", style="green")
|
|
430
|
+
line.append("Rolled back ", style="green")
|
|
431
|
+
line.append(name, style="blue")
|
|
432
|
+
console.print(line)
|
|
433
|
+
return
|
|
434
|
+
ran = migrator.run()
|
|
435
|
+
if not ran:
|
|
436
|
+
console.print(Text("✓ Nothing to migrate.", style="green"))
|
|
437
|
+
return
|
|
438
|
+
for name in ran:
|
|
439
|
+
line = Text()
|
|
440
|
+
line.append("✓ ", style="green")
|
|
441
|
+
line.append("Migrated ", style="green")
|
|
442
|
+
line.append(name, style="blue")
|
|
443
|
+
console.print(line)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def run_seeds(seeder: str | None = None) -> None:
|
|
447
|
+
from future.seeds.SeedRunner import SeedRunner
|
|
448
|
+
|
|
449
|
+
register_database_connections()
|
|
450
|
+
ran = SeedRunner(path="database/seeds").run(name=seeder)
|
|
451
|
+
print(f"Seeded: {ran}")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def make_migration(model_name: str | None = None) -> None:
|
|
455
|
+
from future.migrations.MigrationGenerator import MigrationGenerator
|
|
456
|
+
from rich.console import Console
|
|
457
|
+
from rich.text import Text
|
|
458
|
+
|
|
459
|
+
ensure_project_path()
|
|
460
|
+
console = Console()
|
|
461
|
+
try:
|
|
462
|
+
paths = MigrationGenerator().make(model_name)
|
|
463
|
+
except (FileNotFoundError, ValueError) as error:
|
|
464
|
+
print(f"Error: {error}")
|
|
465
|
+
sys.exit(1)
|
|
466
|
+
if not paths:
|
|
467
|
+
print("No migrations created (no annotated models found).")
|
|
468
|
+
return
|
|
469
|
+
for path in paths:
|
|
470
|
+
line = Text()
|
|
471
|
+
line.append("✓ ", style="green")
|
|
472
|
+
line.append("Migration created successfully! ", style="green")
|
|
473
|
+
line.append(str(path), style="blue")
|
|
474
|
+
console.print(line)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def make_seed(model_name: str | None = None) -> None:
|
|
478
|
+
from future.seeds.SeedGenerator import SeedGenerator
|
|
479
|
+
|
|
480
|
+
ensure_project_path()
|
|
481
|
+
try:
|
|
482
|
+
paths = SeedGenerator().make(model_name)
|
|
483
|
+
except (FileNotFoundError, FileExistsError, ValueError) as error:
|
|
484
|
+
print(f"Error: {error}")
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
if not paths:
|
|
487
|
+
print("No new seeders created (all models already have seeders, or no annotated models found).")
|
|
488
|
+
return
|
|
489
|
+
for path in paths:
|
|
490
|
+
print(f"Created: {path}")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def make_stub(kind: str, name: str) -> None:
|
|
494
|
+
from .stubs import StubMaker
|
|
495
|
+
|
|
496
|
+
ensure_project_path()
|
|
497
|
+
try:
|
|
498
|
+
path = StubMaker().make(kind, name)
|
|
499
|
+
except (FileNotFoundError, FileExistsError, ValueError) as error:
|
|
500
|
+
print(f"Error: {error}")
|
|
501
|
+
sys.exit(1)
|
|
502
|
+
print(f"Created: {path}")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def run_app(host: str | None = None, port: int | None = None, workers: int | None = None) -> None:
|
|
506
|
+
app = get_app_from_project()
|
|
507
|
+
config = getattr(app, "config", {}) or {}
|
|
508
|
+
bind_host = host if host is not None else config.get("APP_HOST", "127.0.0.1")
|
|
509
|
+
bind_port = port if port is not None else int(config.get("APP_PORT", 8000))
|
|
510
|
+
bind_workers = workers if workers is not None else int(config.get("APP_WORKERS", 1))
|
|
511
|
+
app.run(host=bind_host, port=bind_port, workers=bind_workers)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def main() -> None:
|
|
515
|
+
parser = argparse.ArgumentParser(
|
|
516
|
+
description="Future Framework CLI",
|
|
517
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
518
|
+
epilog="""
|
|
519
|
+
examples:
|
|
520
|
+
future init .
|
|
521
|
+
future run
|
|
522
|
+
future routes
|
|
523
|
+
future make:model Trade
|
|
524
|
+
future make:controller Trade
|
|
525
|
+
future make:migration Trade
|
|
526
|
+
future make:migrations
|
|
527
|
+
future make:seed Trade
|
|
528
|
+
future make:seeds
|
|
529
|
+
future migrate
|
|
530
|
+
future migrate rollback
|
|
531
|
+
future seed
|
|
532
|
+
future seed TradeSeeder
|
|
533
|
+
""",
|
|
534
|
+
)
|
|
535
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
536
|
+
|
|
537
|
+
subparsers.add_parser("routes", help="List all routes from the app in run.py")
|
|
538
|
+
|
|
539
|
+
init_parser = subparsers.add_parser("init", help="Scaffold a new Future project (app/, database/, run.py)")
|
|
540
|
+
init_parser.add_argument("target", nargs="?", default=".", help="Target directory (default: current directory)")
|
|
541
|
+
|
|
542
|
+
run_parser = subparsers.add_parser("run", help="Run the app from run.py")
|
|
543
|
+
run_parser.add_argument("--host", default=None, help="Bind host (default: app config or 127.0.0.1)")
|
|
544
|
+
run_parser.add_argument("--port", type=int, default=None, help="Bind port (default: app config or 8000)")
|
|
545
|
+
run_parser.add_argument("--workers", type=int, default=None, help="Worker processes (default: app config or 1)")
|
|
546
|
+
|
|
547
|
+
migrate_parser = subparsers.add_parser("migrate", help="Run or rollback migrations in database/migrations/")
|
|
548
|
+
migrate_parser.add_argument("action", nargs="?", default="run", choices=["run", "rollback"], help="run (default) or rollback last batch")
|
|
549
|
+
|
|
550
|
+
seed_parser = subparsers.add_parser("seed", help="Run seeders in database/seeds/ (loads run.py so Future registers DATABASES)")
|
|
551
|
+
seed_parser.add_argument("seeder", nargs="?", default=None, help="Seeder class name, or omit to run all seeders")
|
|
552
|
+
|
|
553
|
+
make_migration_parser = subparsers.add_parser("make:migration", help="Generate a Schema migration from one Model")
|
|
554
|
+
make_migration_parser.add_argument("model", help="Model class name")
|
|
555
|
+
|
|
556
|
+
subparsers.add_parser("make:migrations", help="Generate Schema migrations for all annotated models")
|
|
557
|
+
|
|
558
|
+
make_seed_parser = subparsers.add_parser("make:seed", help="Generate a seeder from one Model")
|
|
559
|
+
make_seed_parser.add_argument("model", help="Model class name")
|
|
560
|
+
|
|
561
|
+
subparsers.add_parser("make:seeds", help="Generate seeders for all annotated models (skips existing files)")
|
|
562
|
+
|
|
563
|
+
stub_help = {
|
|
564
|
+
"model": "Create app/models/<Name>.py",
|
|
565
|
+
"controller": "Create app/controllers/<Name>Controller.py",
|
|
566
|
+
"middleware": "Create app/middleware/<Name>Middleware.py",
|
|
567
|
+
"plugin": "Create app/plugins/<Name>Plugin.py",
|
|
568
|
+
"task": "Create app/tasks/<Name>.py",
|
|
569
|
+
}
|
|
570
|
+
for kind, help_text in stub_help.items():
|
|
571
|
+
stub_parser = subparsers.add_parser(f"make:{kind}", help=help_text)
|
|
572
|
+
stub_parser.add_argument("name", help=f"{kind.capitalize()} name")
|
|
573
|
+
|
|
574
|
+
args = parser.parse_args()
|
|
575
|
+
|
|
576
|
+
if args.command == "routes":
|
|
577
|
+
print_routes()
|
|
578
|
+
elif args.command == "init":
|
|
579
|
+
if args.target == ".":
|
|
580
|
+
scaffold_project(os.getcwd())
|
|
581
|
+
else:
|
|
582
|
+
scaffold_project(os.path.join(os.getcwd(), args.target))
|
|
583
|
+
elif args.command == "run":
|
|
584
|
+
run_app(host=args.host, port=args.port, workers=args.workers)
|
|
585
|
+
elif args.command == "migrate":
|
|
586
|
+
run_migrations(rollback=(args.action == "rollback"))
|
|
587
|
+
elif args.command == "seed":
|
|
588
|
+
run_seeds(seeder=args.seeder)
|
|
589
|
+
elif args.command == "make:migration":
|
|
590
|
+
make_migration(model_name=args.model)
|
|
591
|
+
elif args.command == "make:migrations":
|
|
592
|
+
make_migration(model_name=None)
|
|
593
|
+
elif args.command == "make:seed":
|
|
594
|
+
make_seed(model_name=args.model)
|
|
595
|
+
elif args.command == "make:seeds":
|
|
596
|
+
make_seed(model_name=None)
|
|
597
|
+
elif args.command and args.command.startswith("make:"):
|
|
598
|
+
kind = args.command.split(":", 1)[1]
|
|
599
|
+
if kind in stub_help:
|
|
600
|
+
make_stub(kind=kind, name=args.name)
|
|
601
|
+
else:
|
|
602
|
+
parser.print_help()
|
|
603
|
+
else:
|
|
604
|
+
parser.print_help()
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
if __name__ == "__main__":
|
|
608
|
+
main()
|