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/settings.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from os import environ as env
|
|
2
|
+
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
load_dotenv(dotenv_path=".env") # FIXME: dotenv not used correctly. Shouldn't need both os.environ and dotenv...
|
|
7
|
+
# os.environ["APP_ENV"] = "dev"
|
|
8
|
+
# port = int(os.environ.get("APP_PORT", 44777))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Application settings sourced from .env
|
|
12
|
+
APP_NAME = str(env.get("APP_NAME", "Future"))
|
|
13
|
+
APP_VERSION = str(env.get("APP_VERSION", "1.0"))
|
|
14
|
+
APP_DESCRIPTION = str(env.get("APP_DESCRIPTION", "A short description"))
|
|
15
|
+
APP_DEBUG = env.get("APP_DEBUG", "False").lower() == "true"
|
|
16
|
+
APP_LOG_LEVEL = str("DEBUG" if APP_DEBUG else env.get("APP_LOG_LEVEL", "INFO"))
|
|
17
|
+
APP_ACCESS_LOG = env.get("APP_ACCESS_LOG", "False").lower() == "true"
|
|
18
|
+
APP_WORKERS = int(env.get("APP_WORKERS", 4))
|
|
19
|
+
APP_HOST = str(env.get("APP_HOST", "127.0.0.1"))
|
|
20
|
+
APP_PORT = int(env.get("APP_PORT", 9000))
|
|
21
|
+
APP_SSO = env.get("APP_SSO", "False").lower() == "true"
|
|
22
|
+
APP_KEY = str(env.get("APP_KEY", "secret")) # os.urandom(24) # TODO: add to cli --generate
|
|
23
|
+
APP_REGISTRATION = env.get("APP_REGISTRATION", "False").lower() == "true"
|
|
24
|
+
APP_SSL_CERT_FILE = str(env.get("APP_SSL_CERT_FILE", "./cert.pem"))
|
|
25
|
+
APP_SSL_KEY_FILE = str(env.get("APP_SSL_KEY_FILE", "./key.pem"))
|
|
26
|
+
APP_SSL_PASSPHRASE = str(env.get("APP_SSL_PASSPHRASE", "changeme"))
|
|
27
|
+
APP_DOMAIN = str(env.get("APP_DOMAIN", "example.com"))
|
|
28
|
+
|
|
29
|
+
# Database settings sourced from .env
|
|
30
|
+
DB_DRIVER = str(env.get("DB_DRIVER", "sqlite"))
|
|
31
|
+
DB_HOST = str(env.get("DB_HOST", "127.0.0.1"))
|
|
32
|
+
DB_PORT = int(env.get("DB_PORT", 3306))
|
|
33
|
+
DB_DATABASE = str(env.get("DB_DATABASE", "database"))
|
|
34
|
+
DB_USERNAME = str(env.get("DB_USERNAME", ""))
|
|
35
|
+
DB_PASSWORD = str(env.get("DB_PASSWORD", ""))
|
|
36
|
+
DB_LOGGING = str(env.get("DB_LOGGING", True))
|
|
37
|
+
DB_OPTIONS = str(env.get("DB_OPTIONS", None))
|
|
38
|
+
|
|
39
|
+
# Elasticsearch settings sourced from .env
|
|
40
|
+
ELASTIC_HOST = str(env.get("ELASTIC_HOST", "127.0.0.1"))
|
|
41
|
+
ELASTIC_PORT = int(env.get("ELASTIC_PORT", 9200))
|
|
42
|
+
ELASTIC_USER = str(env.get("ELASTIC_USER", None))
|
|
43
|
+
ELASTIC_PASS = str(env.get("ELASTIC_PASS", None))
|
|
44
|
+
|
|
45
|
+
# API Spec is built by Future from registered routes when OPENAPI.enabled is True.
|
|
46
|
+
# See config["OPENAPI"] and future.openapi.openapi_routes().
|
|
47
|
+
API_SPEC = {
|
|
48
|
+
"openapi": "3.0.0", # Updated to 3.0.0 for better compatibility
|
|
49
|
+
"info": {
|
|
50
|
+
"title": APP_NAME,
|
|
51
|
+
"version": APP_VERSION,
|
|
52
|
+
"description": APP_DESCRIPTION,
|
|
53
|
+
},
|
|
54
|
+
"paths": {},
|
|
55
|
+
# "servers": [
|
|
56
|
+
# {
|
|
57
|
+
# "url": APP_DOMAIN,
|
|
58
|
+
# "description": "Development server"
|
|
59
|
+
# }
|
|
60
|
+
# ]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Logging configuration
|
|
64
|
+
LOGGING_CONFIG = {
|
|
65
|
+
"version": 1,
|
|
66
|
+
"disable_existing_loggers": False,
|
|
67
|
+
"formatters": {
|
|
68
|
+
"colored": {
|
|
69
|
+
"format": "%(message)s",
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"handlers": {
|
|
73
|
+
"stdout": {
|
|
74
|
+
"class": "logging.StreamHandler",
|
|
75
|
+
"level": APP_LOG_LEVEL,
|
|
76
|
+
"formatter": "colored",
|
|
77
|
+
"stream": "ext://sys.stdout",
|
|
78
|
+
},
|
|
79
|
+
"queue_handler": {
|
|
80
|
+
"class": "logging.handlers.QueueHandler",
|
|
81
|
+
"handlers": [
|
|
82
|
+
"stdout",
|
|
83
|
+
],
|
|
84
|
+
"respect_handler_level": True,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"loggers": {
|
|
88
|
+
"future": {
|
|
89
|
+
"level": APP_LOG_LEVEL,
|
|
90
|
+
"handlers": ["queue_handler"],
|
|
91
|
+
"propagate": False,
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
future/tasks/__init__.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
from future.logger import log
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Unit(Enum):
|
|
12
|
+
SECONDS = "seconds"
|
|
13
|
+
MINUTES = "minutes"
|
|
14
|
+
HOURS = "hours"
|
|
15
|
+
DAYS = "days"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Task:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
name: str,
|
|
22
|
+
interval: Optional[int] = None,
|
|
23
|
+
unit: Optional[Unit] = None,
|
|
24
|
+
start_time: Optional[datetime] = None,
|
|
25
|
+
kwargs: Optional[dict[str, Any]] = None,
|
|
26
|
+
func: Optional[Callable[..., Any]] = None,
|
|
27
|
+
args: tuple[Any, ...] = (),
|
|
28
|
+
jitter: Optional[float] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.name = name
|
|
31
|
+
self.interval = interval
|
|
32
|
+
self.unit = unit
|
|
33
|
+
self.start_time = start_time
|
|
34
|
+
self.kwargs = kwargs or {}
|
|
35
|
+
self.func = func
|
|
36
|
+
self.args = args
|
|
37
|
+
self.jitter = jitter
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ScheduledTask:
|
|
41
|
+
"""Represents a scheduled task with its timing configuration."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
name: str,
|
|
46
|
+
func: Callable[..., Any],
|
|
47
|
+
interval: int,
|
|
48
|
+
unit: Unit,
|
|
49
|
+
start_time: Optional[datetime] = None,
|
|
50
|
+
last_run: Optional[datetime] = None,
|
|
51
|
+
next_run: Optional[datetime] = None,
|
|
52
|
+
args: tuple[Any, ...] = (),
|
|
53
|
+
kwargs: Optional[dict[str, Any]] = None,
|
|
54
|
+
jitter: Optional[float] = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.name = name
|
|
57
|
+
self.func = func
|
|
58
|
+
self.interval = interval
|
|
59
|
+
self.unit = unit
|
|
60
|
+
self.start_time = start_time
|
|
61
|
+
self.last_run = last_run
|
|
62
|
+
self.next_run = next_run
|
|
63
|
+
self.args = args
|
|
64
|
+
self.kwargs = kwargs if kwargs is not None else {}
|
|
65
|
+
self.jitter = jitter
|
|
66
|
+
|
|
67
|
+
if self.start_time is None:
|
|
68
|
+
self.start_time = datetime.now()
|
|
69
|
+
self.calculate_next_run()
|
|
70
|
+
|
|
71
|
+
def calculate_next_run(self) -> None:
|
|
72
|
+
"""Calculate when this task should run next."""
|
|
73
|
+
if self.last_run is None:
|
|
74
|
+
self.next_run = self.start_time
|
|
75
|
+
else:
|
|
76
|
+
if self.unit == Unit.SECONDS:
|
|
77
|
+
self.next_run = self.last_run + timedelta(seconds=self.interval)
|
|
78
|
+
elif self.unit == Unit.MINUTES:
|
|
79
|
+
self.next_run = self.last_run + timedelta(minutes=self.interval)
|
|
80
|
+
elif self.unit == Unit.HOURS:
|
|
81
|
+
self.next_run = self.last_run + timedelta(hours=self.interval)
|
|
82
|
+
elif self.unit == Unit.DAYS:
|
|
83
|
+
self.next_run = self.last_run + timedelta(days=self.interval)
|
|
84
|
+
if self.next_run is not None and self.jitter and self.jitter > 0:
|
|
85
|
+
self.next_run = self.next_run + timedelta(seconds=random.uniform(0, self.jitter))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CronScheduler:
|
|
89
|
+
"""A cron-like scheduler for running background tasks."""
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self.tasks: dict[str, ScheduledTask] = {}
|
|
93
|
+
self.running = False
|
|
94
|
+
self.check_interval = 1.0 # Check every second for tasks to run
|
|
95
|
+
|
|
96
|
+
def add_task(self, task: Task) -> None:
|
|
97
|
+
if task.func is None or task.interval is None or task.unit is None:
|
|
98
|
+
log.warning(f"Skipping task '{task.name}' - missing required parameters")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
scheduled = ScheduledTask(
|
|
102
|
+
name=task.name,
|
|
103
|
+
func=task.func,
|
|
104
|
+
interval=task.interval,
|
|
105
|
+
unit=task.unit,
|
|
106
|
+
start_time=task.start_time,
|
|
107
|
+
args=task.args,
|
|
108
|
+
kwargs=task.kwargs,
|
|
109
|
+
jitter=task.jitter,
|
|
110
|
+
)
|
|
111
|
+
self.tasks[task.name] = scheduled
|
|
112
|
+
jitter_note = f" (jitter 0–{task.jitter}s)" if task.jitter else ""
|
|
113
|
+
log.info(f"Added scheduled task '{task.name}' to run every {task.interval} {task.unit.value}{jitter_note}")
|
|
114
|
+
|
|
115
|
+
def remove_task(self, name: str) -> bool:
|
|
116
|
+
"""Remove a scheduled task."""
|
|
117
|
+
if name in self.tasks:
|
|
118
|
+
del self.tasks[name]
|
|
119
|
+
log.info(f"Removed scheduled task '{name}'")
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def get_task(self, name: str) -> Optional[ScheduledTask]:
|
|
124
|
+
"""Get a scheduled task by name."""
|
|
125
|
+
return self.tasks.get(name)
|
|
126
|
+
|
|
127
|
+
def list_tasks(self) -> list[str]:
|
|
128
|
+
"""List all scheduled task names."""
|
|
129
|
+
return list(self.tasks.keys())
|
|
130
|
+
|
|
131
|
+
async def _run_task(self, task: ScheduledTask) -> None:
|
|
132
|
+
"""Run a single task."""
|
|
133
|
+
try:
|
|
134
|
+
log.debug(f"Running scheduled task '{task.name}'")
|
|
135
|
+
if asyncio.iscoroutinefunction(task.func):
|
|
136
|
+
# Run async functions directly
|
|
137
|
+
await task.func(*task.args, **task.kwargs)
|
|
138
|
+
else:
|
|
139
|
+
# Run sync functions in thread pool
|
|
140
|
+
loop = asyncio.get_event_loop()
|
|
141
|
+
await loop.run_in_executor(None, task.func, *task.args, **task.kwargs)
|
|
142
|
+
|
|
143
|
+
task.last_run = datetime.now()
|
|
144
|
+
task.calculate_next_run()
|
|
145
|
+
if task.next_run:
|
|
146
|
+
log.debug(f"Completed scheduled task '{task.name}', next run at {task.next_run.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
147
|
+
else:
|
|
148
|
+
log.debug(f"Completed scheduled task '{task.name}'")
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
log.error(f"Error running scheduled task '{task.name}': {e}")
|
|
152
|
+
# Don't update last_run on error, so it will retry next cycle
|
|
153
|
+
|
|
154
|
+
async def _scheduler_loop(self) -> None:
|
|
155
|
+
"""Main scheduler loop that checks for tasks to run."""
|
|
156
|
+
log.info("Starting cron scheduler...")
|
|
157
|
+
|
|
158
|
+
while self.running:
|
|
159
|
+
now = datetime.now()
|
|
160
|
+
tasks_to_run = []
|
|
161
|
+
|
|
162
|
+
# Check which tasks need to run
|
|
163
|
+
for task in self.tasks.values():
|
|
164
|
+
if task.next_run and now >= task.next_run:
|
|
165
|
+
tasks_to_run.append(task)
|
|
166
|
+
|
|
167
|
+
# Run tasks that are due
|
|
168
|
+
if tasks_to_run:
|
|
169
|
+
log.debug(f"Running {len(tasks_to_run)} scheduled tasks")
|
|
170
|
+
for task in tasks_to_run:
|
|
171
|
+
asyncio.create_task(self._run_task(task))
|
|
172
|
+
|
|
173
|
+
# Wait before next check
|
|
174
|
+
await asyncio.sleep(self.check_interval)
|
|
175
|
+
|
|
176
|
+
log.info("Cron scheduler stopped")
|
|
177
|
+
|
|
178
|
+
async def start(self) -> None:
|
|
179
|
+
"""Start the scheduler."""
|
|
180
|
+
if not self.running:
|
|
181
|
+
self.running = True
|
|
182
|
+
asyncio.create_task(self._scheduler_loop())
|
|
183
|
+
|
|
184
|
+
async def stop(self) -> None:
|
|
185
|
+
"""Stop the scheduler."""
|
|
186
|
+
self.running = False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- Minimal cron task functions ---
|
|
190
|
+
async def check_dns(domain: str = "example.com") -> None:
|
|
191
|
+
import socket
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
ip = socket.gethostbyname(domain)
|
|
195
|
+
log.info(f"DNS: {domain} -> {ip}")
|
|
196
|
+
except socket.gaierror as e:
|
|
197
|
+
log.error(f"DNS lookup failed for {domain}: {e}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def check_http_status(url: str = "https://httpbin.org/status/200") -> None:
|
|
201
|
+
import httpx
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
async with httpx.AsyncClient() as client:
|
|
205
|
+
response = await client.get(url)
|
|
206
|
+
log.info(f"HTTP: {url} -> {response.status_code}")
|
|
207
|
+
except Exception as e:
|
|
208
|
+
log.error(f"HTTP check failed for {url}: {e}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def check_ssh_banner(host: str = "localhost", port: int = 22) -> None:
|
|
212
|
+
try:
|
|
213
|
+
reader, writer = await asyncio.open_connection(host, port)
|
|
214
|
+
banner = await reader.read(1024)
|
|
215
|
+
log.info(f"SSH banner for {host}:{port}: {banner.decode(errors='ignore').strip()}")
|
|
216
|
+
writer.close()
|
|
217
|
+
await writer.wait_closed()
|
|
218
|
+
except Exception as e:
|
|
219
|
+
log.error(f"SSH banner check failed for {host}:{port}: {e}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_system_uptime() -> None:
|
|
223
|
+
import subprocess
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
result = subprocess.run(["uptime"], capture_output=True, text=True, timeout=5.0)
|
|
227
|
+
if result.returncode == 0:
|
|
228
|
+
log.info(f"System uptime: {result.stdout.strip()}")
|
|
229
|
+
else:
|
|
230
|
+
log.error(f"Uptime command failed: {result.stderr}")
|
|
231
|
+
except subprocess.TimeoutExpired:
|
|
232
|
+
log.error("Uptime command timed out")
|
|
233
|
+
except Exception as e:
|
|
234
|
+
log.error(f"Error checking system uptime: {e}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_disk_usage(path: str = "/") -> None:
|
|
238
|
+
import shutil
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
total, used, _ = shutil.disk_usage(path)
|
|
242
|
+
used_percent = (used / total) * 100
|
|
243
|
+
log.info(f"Disk usage for {path}: {used_percent:.1f}% used ({used // (1024**3)}GB / {total // (1024**3)}GB)")
|
|
244
|
+
except Exception as e:
|
|
245
|
+
log.error(f"Error checking disk usage for {path}: {e}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def check_memory_usage() -> None:
|
|
249
|
+
import psutil
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
memory = psutil.virtual_memory()
|
|
253
|
+
log.info(f"Memory usage: {memory.percent}% used ({memory.used // (1024**3)}GB / {memory.total // (1024**3)}GB)")
|
|
254
|
+
except ImportError:
|
|
255
|
+
log.warning("psutil not available, skipping memory check")
|
|
256
|
+
except Exception as e:
|
|
257
|
+
log.error(f"Error checking memory usage: {e}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def sync_task_example() -> None:
|
|
261
|
+
log.info(f"Running sync task at {datetime.now()}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def daily_backup() -> None:
|
|
265
|
+
log.info(f"Running daily backup at {datetime.now()}")
|
|
266
|
+
# TODO: Implement actual backup logic
|
|
267
|
+
# Example: backup database, files, etc.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from future.testing.client import FutureTestClient
|
future/testing/client.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from urllib.parse import urlparse
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from future.application import Future
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FutureTestClient:
|
|
11
|
+
def __init__(self, app: Future) -> None:
|
|
12
|
+
self.app = app
|
|
13
|
+
self.transport = httpx.ASGITransport(app=self.app)
|
|
14
|
+
self.client = httpx.AsyncClient(transport=self.transport)
|
|
15
|
+
|
|
16
|
+
async def __aenter__(self) -> "FutureTestClient":
|
|
17
|
+
await self.client.__aenter__()
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
21
|
+
await self.client.__aexit__(exc_type, exc, tb)
|
|
22
|
+
|
|
23
|
+
async def get(self, url: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
|
24
|
+
response = await self.client.get(url, headers=headers)
|
|
25
|
+
return response
|
|
26
|
+
|
|
27
|
+
async def post(self, url: str, json: dict[str, Any] | None = None, data: str | bytes | None = None, headers: dict[str, str] | None = None) -> httpx.Response:
|
|
28
|
+
if json is not None:
|
|
29
|
+
response = await self.client.post(url, json=json, headers=headers)
|
|
30
|
+
elif data is not None:
|
|
31
|
+
response = await self.client.post(url, content=data, headers=headers)
|
|
32
|
+
else:
|
|
33
|
+
response = await self.client.post(url, headers=headers)
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
async def websocket_connect(self, url: str, headers: dict[str, str] | None = None) -> Any:
|
|
37
|
+
"""Connect via real ASGI websocket handshake against the Future app."""
|
|
38
|
+
parsed = urlparse(url)
|
|
39
|
+
path = parsed.path
|
|
40
|
+
if parsed.query:
|
|
41
|
+
path += "?" + parsed.query
|
|
42
|
+
|
|
43
|
+
ws_headers = []
|
|
44
|
+
if headers:
|
|
45
|
+
for key, value in headers.items():
|
|
46
|
+
ws_headers.append((key.lower().encode(), value.encode()))
|
|
47
|
+
|
|
48
|
+
scope = {
|
|
49
|
+
"type": "websocket",
|
|
50
|
+
"asgi": {"version": "3.0", "spec_version": "2.3"},
|
|
51
|
+
"path": path,
|
|
52
|
+
"headers": ws_headers,
|
|
53
|
+
"raw_path": path.encode(),
|
|
54
|
+
"query_string": b"",
|
|
55
|
+
"client": ("127.0.0.1", 12345),
|
|
56
|
+
"server": ("127.0.0.1", 8000),
|
|
57
|
+
"scheme": "ws",
|
|
58
|
+
"root_path": "",
|
|
59
|
+
"subprotocols": [],
|
|
60
|
+
"extensions": {},
|
|
61
|
+
"state": {},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
send_queue: asyncio.Queue = asyncio.Queue()
|
|
65
|
+
recv_queue: asyncio.Queue = asyncio.Queue()
|
|
66
|
+
await recv_queue.put({"type": "websocket.connect"})
|
|
67
|
+
|
|
68
|
+
async def receive() -> dict[str, Any]:
|
|
69
|
+
return await recv_queue.get()
|
|
70
|
+
|
|
71
|
+
async def send(message: dict[str, Any]) -> None:
|
|
72
|
+
await send_queue.put(message)
|
|
73
|
+
|
|
74
|
+
task = asyncio.create_task(self.app(scope, receive, send))
|
|
75
|
+
|
|
76
|
+
class AsgiWebSocket:
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
self.accepted = False
|
|
79
|
+
self.closed = False
|
|
80
|
+
self.close_code = None
|
|
81
|
+
self.close_reason = None
|
|
82
|
+
self._task = task
|
|
83
|
+
|
|
84
|
+
async def _next_server_message(self) -> dict[str, Any]:
|
|
85
|
+
while True:
|
|
86
|
+
try:
|
|
87
|
+
return await asyncio.wait_for(send_queue.get(), timeout=2.0)
|
|
88
|
+
except asyncio.TimeoutError:
|
|
89
|
+
if self._task.done():
|
|
90
|
+
exc = self._task.exception()
|
|
91
|
+
if exc:
|
|
92
|
+
raise exc
|
|
93
|
+
raise TimeoutError("WebSocket app ended without a message")
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
async def wait_accepted(self) -> None:
|
|
97
|
+
message = await self._next_server_message()
|
|
98
|
+
if message["type"] == "websocket.close":
|
|
99
|
+
self.closed = True
|
|
100
|
+
self.close_code = message.get("code")
|
|
101
|
+
self.close_reason = message.get("reason")
|
|
102
|
+
raise ConnectionError(f"WebSocket closed: {self.close_code} {self.close_reason}")
|
|
103
|
+
if message["type"] != "websocket.accept":
|
|
104
|
+
raise AssertionError(f"Expected websocket.accept, got {message}")
|
|
105
|
+
self.accepted = True
|
|
106
|
+
|
|
107
|
+
async def send(self, message: str) -> None:
|
|
108
|
+
await recv_queue.put({"type": "websocket.receive", "text": message})
|
|
109
|
+
|
|
110
|
+
async def recv(self) -> str:
|
|
111
|
+
message = await self._next_server_message()
|
|
112
|
+
if message["type"] == "websocket.send":
|
|
113
|
+
if "text" in message:
|
|
114
|
+
return message["text"]
|
|
115
|
+
return message.get("bytes", b"").decode("utf-8")
|
|
116
|
+
if message["type"] == "websocket.close":
|
|
117
|
+
self.closed = True
|
|
118
|
+
self.close_code = message.get("code")
|
|
119
|
+
self.close_reason = message.get("reason")
|
|
120
|
+
raise ConnectionError(f"WebSocket closed: {self.close_code} {self.close_reason}")
|
|
121
|
+
raise AssertionError(f"Unexpected message: {message}")
|
|
122
|
+
|
|
123
|
+
async def close(self) -> None:
|
|
124
|
+
await recv_queue.put({"type": "websocket.disconnect", "code": 1000})
|
|
125
|
+
try:
|
|
126
|
+
await asyncio.wait_for(self._task, timeout=2.0)
|
|
127
|
+
except asyncio.TimeoutError:
|
|
128
|
+
self._task.cancel()
|
|
129
|
+
|
|
130
|
+
socket = AsgiWebSocket()
|
|
131
|
+
await socket.wait_accepted()
|
|
132
|
+
# Greeting frame from WebSocketResponse (duplex send/recv available after)
|
|
133
|
+
first = await socket.recv()
|
|
134
|
+
socket._first = first # type: ignore[attr-defined]
|
|
135
|
+
return socket
|
future/types.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from collections.abc import Awaitable, MutableMapping
|
|
7
|
+
from typing import Any, Callable, TypedDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ASGI specific
|
|
11
|
+
ASGIScope = MutableMapping[str, Any]
|
|
12
|
+
ASGIMessage = MutableMapping[str, Any]
|
|
13
|
+
ASGISend = Callable[[ASGIMessage], Awaitable[None]]
|
|
14
|
+
ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsgiEventType(enum.StrEnum):
|
|
18
|
+
LIFESPAN_STARTUP = "lifespan.startup"
|
|
19
|
+
LIFESPAN_STARTUP_COMPLETE = "lifespan.startup.complete"
|
|
20
|
+
LIFESPAN_STARTUP_FAILED = "lifespan.startup.failed"
|
|
21
|
+
LIFESPAN_SHUTDOWN = "lifespan.shutdown"
|
|
22
|
+
LIFESPAN_SHUTDOWN_COMPLETE = "lifespan.shutdown.complete"
|
|
23
|
+
LIFESPAN_SHUTDOWN_FAILED = "lifespan.shutdown.failed"
|
|
24
|
+
HTTP_REQUEST = "http.request"
|
|
25
|
+
HTTP_RESPONSE_START = "http.response.start"
|
|
26
|
+
HTTP_RESPONSE_BODY = "http.response.body"
|
|
27
|
+
WEBSOCKET_CONNECT = "websocket.connect"
|
|
28
|
+
WEBSOCKET_ACCEPT = "websocket.accept"
|
|
29
|
+
WEBSOCKET_RECEIVE = "websocket.receive"
|
|
30
|
+
WEBSOCKET_SEND = "websocket.send"
|
|
31
|
+
WEBSOCKET_DISCONNECT = "websocket.disconnect"
|
|
32
|
+
WEBSOCKET_CLOSE = "websocket.close"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RegexConfig(TypedDict):
|
|
36
|
+
paths: list[re.Pattern[str]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RouteConfig(TypedDict):
|
|
40
|
+
handler: Callable[..., Any]
|
|
41
|
+
controller: Any
|
|
42
|
+
action: str | None
|
|
43
|
+
middleware: dict[str, list[Any]] # type: ignore
|
|
44
|
+
regex: dict[str, list[re.Pattern[str] | re.Pattern[bytes]]] | None
|
|
45
|
+
methods: list[str]
|
|
46
|
+
route: Any # FIXME: Added in temporarily because of a regression in the regex matching causing /users/123/test to not match /users/123/test/. Not sure when this happened. This shouldnt be needed.
|
|
47
|
+
group: dict[str, str]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: future-framework
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Next Gen. ASGI Framework for minimal Web APIs
|
|
5
|
+
License: LICENSE
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: nicolaipre
|
|
8
|
+
Requires-Python: >=3.12,<4.0
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: clickhouse-driver (>=0.2.11,<0.3.0)
|
|
15
|
+
Requires-Dist: elasticsearch (>=9.4.0,<10.0.0)
|
|
16
|
+
Requires-Dist: faker (>=40.0.0,<41.0.0)
|
|
17
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
18
|
+
Requires-Dist: inflection (>=0.5.1,<0.6.0)
|
|
19
|
+
Requires-Dist: psycopg2-binary (>=2.9.12,<3.0.0)
|
|
20
|
+
Requires-Dist: pymongo (>=4.17.0,<5.0.0)
|
|
21
|
+
Requires-Dist: pymysql (>=1.2.0,<2.0.0)
|
|
22
|
+
Requires-Dist: python-dotenv (>=1.2.2,<2.0.0)
|
|
23
|
+
Requires-Dist: redis (>=8.0.0,<9.0.0)
|
|
24
|
+
Requires-Dist: rich (>=15.0.0,<16.0.0)
|
|
25
|
+
Requires-Dist: sqlalchemy (>=2.0.51,<3.0.0)
|
|
26
|
+
Requires-Dist: strawberry-graphql (>=0.323.0,<0.324.0)
|
|
27
|
+
Requires-Dist: uvicorn (>=0.51.0,<0.52.0)
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Future
|
|
31
|
+
Minimal, decorator-free [ASGI](https://asgi.readthedocs.io/) framework for Python APIs — routing, middleware, Active Record, migrations, seeds, OpenAPI, and interval tasks.
|
|
32
|
+
|
|
33
|
+
[](https://github.com/nicolaipre/future/actions)
|
|
34
|
+
[](https://pypi.org/project/future-framework/)
|
|
35
|
+
[](https://codecov.io/gh/nicolaipre/future)
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
Published at **[nicolaipre.github.io/future](https://nicolaipre.github.io/future/)**. To preview locally: `poetry install --with docs && poetry run mkdocs serve`.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
```bash
|
|
42
|
+
poetry add future-framework
|
|
43
|
+
# or from git: poetry add git+https://github.com/nicolaipre/future.git@master
|
|
44
|
+
poetry run future init myproject
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Import name is `future` (`from future.application import Future`).
|
|
48
|
+
|
|
49
|
+
## Hello
|
|
50
|
+
```python
|
|
51
|
+
from future.application import Future
|
|
52
|
+
from future.controllers import Controller
|
|
53
|
+
from future.lifespan import Lifespan
|
|
54
|
+
from future.response import Response
|
|
55
|
+
from future.routing import Get, RouteGroup
|
|
56
|
+
|
|
57
|
+
class HomeController(Controller):
|
|
58
|
+
async def index(self) -> Response:
|
|
59
|
+
return self.response.json({"ok": True})
|
|
60
|
+
|
|
61
|
+
app = Future(lifespan=Lifespan([], [], []), config={"APP_DOMAIN": "", "APP_NAME": "Demo"})
|
|
62
|
+
app.add_routes([RouteGroup(name="Main", routes=[Get("/", HomeController.index, "home")])])
|
|
63
|
+
app.run(host="127.0.0.1", port=8000)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
See [LICENSE](LICENSE). Versioning: [CHANGELOG.md](CHANGELOG.md).
|
|
68
|
+
|