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.
Files changed (64) hide show
  1. future/__init__.py +3 -0
  2. future/application.py +656 -0
  3. future/authentication/Auth0Authentication.py +9 -0
  4. future/authentication/Authentication.py +11 -0
  5. future/authentication/AzureADAuthentication.py +9 -0
  6. future/authentication/BasicAuthentication.py +9 -0
  7. future/authentication/KerberosAuthentication.py +9 -0
  8. future/authentication/KeycloakAuthentication.py +9 -0
  9. future/authentication/OAuth2Authentication.py +9 -0
  10. future/authentication/OpenIdConnectAuthentication.py +9 -0
  11. future/authentication/SAMLAuthentication.py +9 -0
  12. future/cli/__init__.py +1 -0
  13. future/cli/main.py +608 -0
  14. future/cli/stubs.py +114 -0
  15. future/controllers/__init__.py +4 -0
  16. future/controllers/base.py +8 -0
  17. future/controllers/builtins.py +41 -0
  18. future/controllers/graphql.py +23 -0
  19. future/controllers/openapi.py +224 -0
  20. future/databases/Clickhouse.py +176 -0
  21. future/databases/Connections.py +20 -0
  22. future/databases/Database.py +66 -0
  23. future/databases/Elasticsearch.py +146 -0
  24. future/databases/MongoDB.py +177 -0
  25. future/databases/MySQL.py +215 -0
  26. future/databases/Postgres.py +214 -0
  27. future/databases/Redis.py +146 -0
  28. future/databases/SQLite.py +219 -0
  29. future/exceptions.py +27 -0
  30. future/graphql/__init__.py +1 -0
  31. future/graphql/schema.py +148 -0
  32. future/lifespan.py +70 -0
  33. future/logger.py +53 -0
  34. future/middleware/Middleware.py +201 -0
  35. future/middleware/SessionMiddleware.py +77 -0
  36. future/middleware/__init__.py +21 -0
  37. future/migrations/Blueprint.py +54 -0
  38. future/migrations/Column.py +33 -0
  39. future/migrations/Migration.py +11 -0
  40. future/migrations/MigrationGenerator.py +137 -0
  41. future/migrations/Migrator.py +72 -0
  42. future/migrations/Schema.py +21 -0
  43. future/models/__init__.py +1 -0
  44. future/models/model.py +224 -0
  45. future/openapi.py +233 -0
  46. future/plugins/ElasticsearchPlugin.py +496 -0
  47. future/plugins/__init__.py +9 -0
  48. future/request.py +143 -0
  49. future/response.py +201 -0
  50. future/routing.py +342 -0
  51. future/seeds/SeedGenerator.py +95 -0
  52. future/seeds/SeedRunner.py +42 -0
  53. future/seeds/Seeder.py +9 -0
  54. future/settings.py +94 -0
  55. future/tasks/__init__.py +14 -0
  56. future/tasks/scheduler.py +267 -0
  57. future/testing/__init__.py +1 -0
  58. future/testing/client.py +135 -0
  59. future/types.py +47 -0
  60. future_framework-1.1.0.dist-info/METADATA +68 -0
  61. future_framework-1.1.0.dist-info/RECORD +64 -0
  62. future_framework-1.1.0.dist-info/WHEEL +4 -0
  63. future_framework-1.1.0.dist-info/entry_points.txt +3 -0
  64. future_framework-1.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,496 @@
1
+ from future.plugins import Plugin
2
+ from elasticsearch import Elasticsearch, helpers
3
+
4
+
5
+ class ElasticsearchPlugin(Plugin):
6
+ # __type__ = "database"
7
+
8
+
9
+ """Plugin/Database class for Elasticsearch operations - provides ORM-like interface"""
10
+
11
+ def __init__(self, host, username, password):
12
+ self.es = Elasticsearch(host, basic_auth=(username, password))
13
+ self.host = host
14
+ self._username = username
15
+ self._password = password
16
+ #self.auth = ES_AUTH
17
+
18
+ def create_index(self, index_name, mapping=None, settings=None):
19
+ """Create a new index with optional mapping and settings"""
20
+ try:
21
+ payload = {}
22
+ if mapping:
23
+ payload["mappings"] = mapping
24
+ if settings:
25
+ payload["settings"] = settings
26
+
27
+ result = self.es.indices.create(index=index_name, body=payload)
28
+ return {"status": "Index created", "index": index_name, "result": result}
29
+ except Exception as e:
30
+ return {"error": str(e)}
31
+
32
+ def delete_index(self, index_name):
33
+ """Delete an index"""
34
+ try:
35
+ result = self.es.indices.delete(index=index_name)
36
+ return {"status": f"Index {index_name} deleted", "result": result}
37
+ except Exception as e:
38
+ return {"error": str(e)}
39
+
40
+ def index_exists(self, index_name):
41
+ """Check if an index exists"""
42
+ try:
43
+ return self.es.indices.exists(index=index_name)
44
+ except Exception:
45
+ return False
46
+
47
+ def get_index_info(self, index_name=None):
48
+ """Get information about an index or all indices"""
49
+ try:
50
+ if index_name:
51
+ result = self.es.indices.get(index=index_name)
52
+ else:
53
+ result = self.es.cat.indices(format="json")
54
+ return result
55
+ except Exception as e:
56
+ return {"error": str(e)}
57
+
58
+ def create_alias(self, index_name, alias_name):
59
+ """Create an alias for an index"""
60
+ try:
61
+ result = self.es.indices.put_alias(index=index_name, name=alias_name)
62
+ return {"status": f"Alias {alias_name} created for index {index_name}", "result": result}
63
+ except Exception as e:
64
+ return {"error": str(e)}
65
+
66
+ def delete_alias(self, index_name, alias_name):
67
+ """Delete an alias"""
68
+ try:
69
+ result = self.es.indices.delete_alias(index=index_name, name=alias_name)
70
+ return {"status": f"Alias {alias_name} removed from index {index_name}", "result": result}
71
+ except Exception as e:
72
+ return {"error": str(e)}
73
+
74
+ def index_document(self, index_name, document, document_id=None):
75
+ """Index a single document"""
76
+ try:
77
+ if document_id:
78
+ result = self.es.index(index=index_name, id=document_id, body=document, refresh=True)
79
+ else:
80
+ result = self.es.index(index=index_name, body=document, refresh=True)
81
+
82
+ return {"status": "Document indexed", "result": result}
83
+ except Exception as e:
84
+ return {"error": str(e)}
85
+
86
+ # FIXME: this should definitely not be the way bulk is used. Why queue up a python list and then send it to the server?
87
+ def bulk_index(self, index_name, documents, bulk_options=None):
88
+ """Bulk index multiple documents with chunking (prevents timeouts on large payloads)"""
89
+ try:
90
+ # Prepare bulk request body
91
+ #bulk_body = []
92
+ #for doc in documents:
93
+ # bulk_body.append({"index": {"_index": index_name}})
94
+ # bulk_body.append(doc)
95
+
96
+ #result = self.es.bulk(body=bulk_body)
97
+ result = helpers.bulk(self.es, index=index_name, actions=documents)
98
+ return result
99
+ except Exception as e:
100
+ return {"error": str(e)}
101
+
102
+ def bulk_actions(self, actions, raise_on_error=False):
103
+ """Bulk index/update/delete using elasticsearch.helpers.bulk action dicts."""
104
+ try:
105
+ success, errors = helpers.bulk(self.es, actions, raise_on_error=raise_on_error)
106
+ error_count = len(errors) if errors else 0
107
+ return success, error_count
108
+ except Exception:
109
+ return 0, 1 if raise_on_error else 0
110
+
111
+ def scroll_document_ids(self, index_name, query=None, page_size=1000, scroll_ttl="2m"):
112
+ """Scroll an index and return all document IDs."""
113
+ if not self.index_exists(index_name):
114
+ return set()
115
+
116
+ if query is None:
117
+ query = {"match_all": {}}
118
+
119
+ known: set[str] = set()
120
+ response = self.es.search(
121
+ index=index_name,
122
+ scroll=scroll_ttl,
123
+ size=page_size,
124
+ _source=False,
125
+ query=query,
126
+ )
127
+ scroll_id = response.get("_scroll_id")
128
+ hits = response.get("hits", {}).get("hits", [])
129
+ known.update(hit["_id"] for hit in hits)
130
+
131
+ while hits:
132
+ response = self.es.scroll(scroll_id=scroll_id, scroll=scroll_ttl)
133
+ scroll_id = response.get("_scroll_id")
134
+ hits = response.get("hits", {}).get("hits", [])
135
+ known.update(hit["_id"] for hit in hits)
136
+
137
+ if scroll_id:
138
+ self.es.clear_scroll(scroll_id=scroll_id)
139
+
140
+ return known
141
+
142
+
143
+ def search_documents(self, index_name, query, search_options=None):
144
+ """Search documents using Elasticsearch query DSL with configurable options"""
145
+ try:
146
+ # Apply search options to the query
147
+ if search_options:
148
+ query.update(search_options)
149
+
150
+ result = self.es.search(index=index_name, body=query)
151
+ return result
152
+ except Exception as e:
153
+ return {"error": str(e)}
154
+
155
+ def search(self, index_name, **kwargs):
156
+ """Pass-through search for full Elasticsearch query DSL (including aggregations)."""
157
+ try:
158
+ return self.es.search(index=index_name, **kwargs)
159
+ except Exception as e:
160
+ return {"error": str(e)}
161
+
162
+ def get_document(self, index_name, document_id):
163
+ """Get a specific document by ID"""
164
+ try:
165
+ result = self.es.get(index=index_name, id=document_id)
166
+ return result
167
+ except Exception as e:
168
+ return {"error": str(e)}
169
+
170
+ def update_document(self, index_name, document_id, update_data):
171
+ """Update a document"""
172
+ try:
173
+ result = self.es.update(index=index_name, id=document_id, body={"doc": update_data})
174
+ return {"status": "Document updated", "result": result}
175
+ except Exception as e:
176
+ return {"error": str(e)}
177
+
178
+ def delete_document(self, index_name, document_id):
179
+ """Delete a document"""
180
+ try:
181
+ result = self.es.delete(index=index_name, id=document_id)
182
+ return {"status": f"Document {document_id} deleted", "result": result}
183
+ except Exception as e:
184
+ return {"error": str(e)}
185
+
186
+ def get_index_settings(self, index_name):
187
+ """Get index settings"""
188
+ try:
189
+ result = self.es.indices.get_settings(index=index_name)
190
+ return result
191
+ except Exception as e:
192
+ return {"error": str(e)}
193
+
194
+ def update_index_settings(self, index_name, settings):
195
+ """Update index settings"""
196
+ try:
197
+ result = self.es.indices.put_settings(index=index_name, body=settings)
198
+ return {"status": "Settings updated", "result": result}
199
+ except Exception as e:
200
+ return {"error": str(e)}
201
+
202
+ def get_index_mapping(self, index_name):
203
+ """Get index mapping"""
204
+ try:
205
+ result = self.es.indices.get_mapping(index=index_name)
206
+ return result
207
+ except Exception as e:
208
+ return {"error": str(e)}
209
+
210
+ def update_index_mapping(self, index_name, mapping):
211
+ """Update index mapping"""
212
+ try:
213
+ result = self.es.indices.put_mapping(index=index_name, body=mapping)
214
+ return {"status": "Mapping updated", "result": result}
215
+ except Exception as e:
216
+ return {"error": str(e)}
217
+
218
+ def reindex(self, source_index, dest_index, query=None):
219
+ """Reindex from source to destination index"""
220
+ try:
221
+ if query is None:
222
+ query = {"match_all": {}}
223
+
224
+ body = {
225
+ "source": {
226
+ "index": source_index,
227
+ "query": query
228
+ },
229
+ "dest": {
230
+ "index": dest_index
231
+ }
232
+ }
233
+
234
+ result = self.es.reindex(body=body)
235
+ return {"status": "Reindexing started", "result": result}
236
+ except Exception as e:
237
+ return {"error": str(e)}
238
+
239
+ def refresh_index(self, index_name):
240
+ """Refresh an index to make recent changes visible"""
241
+ try:
242
+ result = self.es.indices.refresh(index=index_name)
243
+ return {"status": f"Index {index_name} refreshed", "result": result}
244
+ except Exception as e:
245
+ return {"error": str(e)}
246
+
247
+ def flush_index(self, index_name):
248
+ """Flush an index to disk"""
249
+ try:
250
+ result = self.es.indices.flush(index=index_name)
251
+ return {"status": f"Index {index_name} flushed", "result": result}
252
+ except Exception as e:
253
+ return {"error": str(e)}
254
+
255
+ def rename_index(self, old_index_name, new_index_name, delete_old=True):
256
+ """Rename an index by reindexing to a new name (Elasticsearch doesn't support direct renaming)"""
257
+ try:
258
+ # Step 1: Check if old index exists
259
+ if not self.index_exists(old_index_name):
260
+ return {"error": f"Source index {old_index_name} does not exist"}
261
+
262
+ # Step 2: Check if new index already exists
263
+ if self.index_exists(new_index_name):
264
+ return {"error": f"Target index {new_index_name} already exists"}
265
+
266
+ # Step 3: Get the mapping and settings from old index
267
+ old_mapping = self.es.indices.get_mapping(index=old_index_name)
268
+ old_settings = self.es.indices.get_settings(index=old_index_name)
269
+
270
+ # Step 4: Create new index with same mapping and settings
271
+ new_index_body = {
272
+ "mappings": old_mapping[old_index_name]["mappings"],
273
+ "settings": old_settings[old_index_name]["settings"]["index"]
274
+ }
275
+
276
+ # Remove settings that can't be copied
277
+ if "creation_date" in new_index_body["settings"]:
278
+ del new_index_body["settings"]["creation_date"]
279
+ if "version" in new_index_body["settings"]:
280
+ del new_index_body["settings"]["version"]
281
+ if "uuid" in new_index_body["settings"]:
282
+ del new_index_body["settings"]["uuid"]
283
+
284
+ self.es.indices.create(index=new_index_name, body=new_index_body)
285
+
286
+ # Step 5: Reindex all data
287
+ reindex_body = {
288
+ "source": {"index": old_index_name},
289
+ "dest": {"index": new_index_name}
290
+ }
291
+
292
+ reindex_result = self.es.reindex(body=reindex_body)
293
+
294
+ # Step 6: Delete old index if requested
295
+ if delete_old:
296
+ self.es.indices.delete(index=old_index_name)
297
+ return {
298
+ "status": f"Index renamed from {old_index_name} to {new_index_name}",
299
+ "old_index_deleted": True,
300
+ "reindex_result": reindex_result
301
+ }
302
+ else:
303
+ return {
304
+ "status": f"Index copied from {old_index_name} to {new_index_name}",
305
+ "old_index_deleted": False,
306
+ "reindex_result": reindex_result
307
+ }
308
+
309
+ except Exception as e:
310
+ return {"error": str(e)}
311
+
312
+ def copy_index(self, source_index_name, target_index_name):
313
+ """Copy an index to a new name (keeps original)"""
314
+ return self.rename_index(source_index_name, target_index_name, delete_old=False)
315
+
316
+ def get_updatable_settings(self, index_name):
317
+ """Get a list of index settings that can be updated"""
318
+ try:
319
+ # Get current settings
320
+ current_settings = self.es.indices.get_settings(index=index_name)
321
+ index_settings = current_settings[index_name]["settings"]["index"]
322
+
323
+ # Filter out settings that cannot be updated
324
+ updatable_settings = {}
325
+ for key, value in index_settings.items():
326
+ if key not in ["creation_date", "version", "uuid", "number_of_shards"]:
327
+ updatable_settings[key] = value
328
+
329
+ return {
330
+ "updatable_settings": updatable_settings,
331
+ "all_settings": index_settings,
332
+ "note": "Only updatable settings are shown above. Core settings like shards cannot be changed."
333
+ }
334
+ except Exception as e:
335
+ return {"error": str(e)}
336
+
337
+ def update_index_metadata(self, index_name, metadata_updates):
338
+ """Update index metadata (settings that can be modified)"""
339
+ try:
340
+ # Validate that we're not trying to update immutable settings
341
+ immutable_settings = ["number_of_shards", "creation_date", "version", "uuid"]
342
+ for setting in immutable_settings:
343
+ if setting in metadata_updates:
344
+ return {"error": f"Cannot update immutable setting: {setting}"}
345
+
346
+ # Update the settings
347
+ result = self.es.indices.put_settings(index=index_name, body=metadata_updates)
348
+ return {
349
+ "status": "Index metadata updated",
350
+ "updated_settings": metadata_updates,
351
+ "result": result
352
+ }
353
+ except Exception as e:
354
+ return {"error": str(e)}
355
+
356
+ def get_index_stats(self, index_name):
357
+ """Get comprehensive index statistics"""
358
+ try:
359
+ stats = self.es.indices.stats(index=index_name)
360
+ return stats
361
+ except Exception as e:
362
+ return {"error": str(e)}
363
+
364
+ def force_merge_index(self, index_name, max_num_segments=1):
365
+ """Force merge index segments for optimization"""
366
+ try:
367
+ result = self.es.indices.forcemerge(index=index_name, max_num_segments=max_num_segments)
368
+ return {"status": f"Index {index_name} force merged", "result": result}
369
+ except Exception as e:
370
+ return {"error": str(e)}
371
+
372
+ def close_index(self, index_name):
373
+ """Close an index (makes it read-only and frees up memory)"""
374
+ try:
375
+ result = self.es.indices.close(index=index_name)
376
+ return {"status": f"Index {index_name} closed", "result": result}
377
+ except Exception as e:
378
+ return {"error": str(e)}
379
+
380
+ def open_index(self, index_name):
381
+ """Open a closed index"""
382
+ try:
383
+ result = self.es.indices.open(index=index_name)
384
+ return {"status": f"Index {index_name} opened", "result": result}
385
+ except Exception as e:
386
+ return {"error": str(e)}
387
+
388
+ def create_api_key(self, index_name, alias="logs", elastic_user=None, elastic_pass=None, privileges=None, role_name="writer", expiration=None, metadata=None):
389
+ """Create API key for a specific index (for SIEM use case) with configurable options"""
390
+ try:
391
+ # Default to configured credentials if none provided
392
+ if elastic_user is None:
393
+ elastic_user = self._username
394
+ if elastic_pass is None:
395
+ elastic_pass = self._password
396
+
397
+ # Default privileges if none specified
398
+ if privileges is None:
399
+ privileges = ["write", "create_index", "read"]
400
+
401
+ # Step 1: Create index with best compression (ZSTD in ES 8.x)
402
+ index_body = {
403
+ "settings": {
404
+ "index.codec": "best_compression"
405
+ },
406
+ "mappings": {
407
+ "_source": { "enabled": True },
408
+ "properties": {
409
+ "timestamp": { "type": "date" },
410
+ "level": { "type": "keyword" },
411
+ "message": { "type": "text" },
412
+ "user": { "type": "keyword" },
413
+ "command": { "type": "keyword" },
414
+ "source": { "type": "keyword" },
415
+ "target": { "type": "keyword" }
416
+ }
417
+ }
418
+ }
419
+
420
+ # Use the ES client for index creation
421
+ if not self.index_exists(index_name):
422
+ self.es.indices.create(index=index_name, body=index_body)
423
+
424
+ # Step 2: Assign alias
425
+ try:
426
+ # Prefer setting write index when creating an alias
427
+ self.es.indices.put_alias(index=index_name, name=alias, body={"is_write_index": True})
428
+ except Exception:
429
+ # Fallback to simple alias if cluster/version does not support body param
430
+ self.es.indices.put_alias(index=index_name, name=alias)
431
+
432
+ # Step 3: Create API key - this still needs HTTP request as ES client doesn't have security API
433
+ import requests
434
+ api_key_payload = {
435
+ "name": f"{index_name}-token",
436
+ "role_descriptors": {
437
+ role_name: {
438
+ "index": [{
439
+ "names": [alias],
440
+ "privileges": privileges
441
+ }]
442
+ }
443
+ }
444
+ }
445
+
446
+ # Add optional parameters if provided
447
+ if expiration:
448
+ api_key_payload["expiration"] = expiration
449
+ if metadata:
450
+ api_key_payload["metadata"] = metadata
451
+
452
+ resp = requests.post(
453
+ f"{self.host}/_security/api_key",
454
+ auth=(elastic_user, elastic_pass),
455
+ json=api_key_payload
456
+ )
457
+ resp.raise_for_status()
458
+ key = resp.json()["api_key"]
459
+ return {
460
+ "status": "API key created",
461
+ "index": index_name,
462
+ "alias": alias,
463
+ "api_key": key,
464
+ "role_name": role_name,
465
+ "privileges": privileges,
466
+ "expiration": expiration,
467
+ "metadata": metadata,
468
+ "config": {
469
+ "auth.user": "token",
470
+ "auth.password": key
471
+ }
472
+ }
473
+ except Exception as e:
474
+ return {"error": str(e)}
475
+
476
+ def bulk_insert(self, index_name, docs, bulk_options=None):
477
+ """Bulk insert documents (for SIEM use case) with configurable options"""
478
+ try:
479
+ # Use the ES client bulk method
480
+ bulk_body = []
481
+ for doc in docs:
482
+ bulk_body.append({"index": {"_index": index_name}})
483
+ bulk_body.append(doc)
484
+
485
+ # Apply bulk options if provided
486
+ if bulk_options:
487
+ result = self.es.bulk(body=bulk_body, **bulk_options) # compare to: es.bulk_actions(actions, raise_on_error=False)
488
+ else:
489
+ result = self.es.bulk(body=bulk_body)
490
+
491
+ return {"status": f"Inserted {len(docs)} documents", "result": result}
492
+ except Exception as e:
493
+ return {"error": str(e)}
494
+
495
+
496
+ # Legacy methods removed (used undefined 'es')
@@ -0,0 +1,9 @@
1
+ """
2
+ This is the base Plugin meant to be used as an interface for other plugins.
3
+
4
+ TODO: Consider renaming to Service or Services.
5
+ """
6
+
7
+
8
+ class Plugin:
9
+ name = None
future/request.py ADDED
@@ -0,0 +1,143 @@
1
+ from future.types import ASGIReceive, ASGIScope
2
+ from urllib.parse import parse_qs
3
+ from typing import Any
4
+ import json
5
+
6
+
7
+ class UploadedFile:
8
+ def __init__(self, filename: str, content_type: str, content: bytes):
9
+ self.filename = filename
10
+ self.content_type = content_type
11
+ self.content = content
12
+
13
+ def __repr__(self):
14
+ return f"UploadedFile(filename={self.filename!r}, content_type={self.content_type!r}, size={len(self.content)})"
15
+
16
+
17
+ class Request:
18
+ def __init__(self, scope: ASGIScope, receive: ASGIReceive):
19
+ self.scope = scope
20
+ self.receive = receive
21
+ self.method = scope.get("method") or ("WEBSOCKET" if scope.get("type") == "websocket" else "")
22
+ self.path = scope["path"]
23
+ self.route = None
24
+ # ASGI headers are byte pairs; decode once for app code
25
+ self.headers = dict([(key.decode("utf-8"), value.decode("utf-8")) for key, value in scope["headers"]])
26
+ self.host = self.headers.get("host", "")
27
+ # self.host = dict(scope['headers']).get(b'host', b'').decode()
28
+ self.context: dict[str, Any] = {} # for custom data we inject into the request
29
+ self.scheme = scope["scheme"]
30
+ self.session = {}
31
+ self.cookies: dict[str, str] = {}
32
+ self._body: bytes | None = None
33
+ self._form: dict[str, Any] | None = None
34
+ self._files: dict[str, Any] | None = None
35
+ cookie_header = self.headers.get("cookie", "")
36
+ if cookie_header:
37
+ for part in cookie_header.split(";"):
38
+ part = part.strip()
39
+ if not part or "=" not in part:
40
+ continue
41
+ name, value = part.split("=", 1)
42
+ self.cookies[name.strip()] = value.strip()
43
+ # Query: single value as str, repeated keys as list[str]
44
+ self.query: dict[str, Any] = {}
45
+ raw_qs = scope.get("query_string", b"") or b""
46
+ if isinstance(raw_qs, bytes):
47
+ raw_qs = raw_qs.decode("utf-8")
48
+ for key, values in parse_qs(raw_qs, keep_blank_values=True).items():
49
+ self.query[key] = values[0] if len(values) == 1 else values
50
+
51
+ async def body(self) -> bytes:
52
+ if self._body is not None:
53
+ return self._body
54
+ more_body = True
55
+ body: bytes = b""
56
+ while more_body:
57
+ message = await self.receive()
58
+ body += message.get("body", b"")
59
+ more_body = message.get("more_body", False)
60
+ self._body = body
61
+ return body
62
+
63
+ async def json(self) -> dict[str, Any]:
64
+ body = await self.body()
65
+ return json.loads(body)
66
+
67
+ async def form(self) -> dict[str, Any]:
68
+ if self._form is not None:
69
+ return self._form
70
+ content_type = self.headers.get("content-type", "")
71
+ body = await self.body()
72
+ if "multipart/form-data" in content_type.lower():
73
+ fields, files = self._parse_multipart(content_type, body)
74
+ self._form = fields
75
+ self._files = files
76
+ return self._form
77
+ parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
78
+ self._form = {key: (values[0] if len(values) == 1 else values) for key, values in parsed.items()}
79
+ self._files = {}
80
+ return self._form
81
+
82
+ async def files(self) -> dict[str, Any]:
83
+ if self._files is None:
84
+ await self.form()
85
+ return self._files or {}
86
+
87
+ def _parse_multipart(self, content_type: str, body: bytes):
88
+ boundary = None
89
+ for part in content_type.split(";"):
90
+ part = part.strip()
91
+ if part.lower().startswith("boundary="):
92
+ boundary = part.split("=", 1)[1].strip().strip('"')
93
+ break
94
+ if not boundary:
95
+ raise ValueError("multipart/form-data missing boundary")
96
+ delimiter = b"--" + boundary.encode("utf-8")
97
+ fields: dict[str, Any] = {}
98
+ files: dict[str, Any] = {}
99
+ for raw_part in body.split(delimiter):
100
+ if not raw_part or raw_part in (b"--", b"--\r\n", b"\r\n"):
101
+ continue
102
+ if raw_part.startswith(b"--"):
103
+ break
104
+ if raw_part.startswith(b"\r\n"):
105
+ raw_part = raw_part[2:]
106
+ if raw_part.endswith(b"\r\n"):
107
+ raw_part = raw_part[:-2]
108
+ header_blob, _, content = raw_part.partition(b"\r\n\r\n")
109
+ if not header_blob:
110
+ continue
111
+ disposition = ""
112
+ part_type = "text/plain"
113
+ for header_line in header_blob.decode("utf-8", errors="replace").split("\r\n"):
114
+ lower = header_line.lower()
115
+ if lower.startswith("content-disposition:"):
116
+ disposition = header_line.split(":", 1)[1].strip()
117
+ elif lower.startswith("content-type:"):
118
+ part_type = header_line.split(":", 1)[1].strip()
119
+ name = None
120
+ filename = None
121
+ for item in disposition.split(";"):
122
+ item = item.strip()
123
+ if item.startswith("name="):
124
+ name = item.split("=", 1)[1].strip().strip('"')
125
+ elif item.startswith("filename="):
126
+ filename = item.split("=", 1)[1].strip().strip('"')
127
+ if not name:
128
+ continue
129
+ if filename is not None:
130
+ uploaded = UploadedFile(filename=filename, content_type=part_type, content=content)
131
+ if name in files:
132
+ existing = files[name]
133
+ files[name] = existing + [uploaded] if isinstance(existing, list) else [existing, uploaded]
134
+ else:
135
+ files[name] = uploaded
136
+ else:
137
+ text = content.decode("utf-8", errors="replace")
138
+ if name in fields:
139
+ existing = fields[name]
140
+ fields[name] = existing + [text] if isinstance(existing, list) else [existing, text]
141
+ else:
142
+ fields[name] = text
143
+ return fields, files