swiftpy-framework 0.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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,935 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import sys
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from swiftpy.auth.api_key_guard import (
11
+ generate_api_key,
12
+ get_server_pepper,
13
+ hash_api_key,
14
+ )
15
+ from swiftpy.console.stubs import get_stub
16
+ from swiftpy.core.config import ConfigurationError
17
+ from swiftpy.database.identifiers import validate_identifier
18
+ from swiftpy.support.strings import pluralize, snake_case
19
+
20
+ _SEGMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
21
+
22
+
23
+ def _pascal_case(snake_str: str) -> str:
24
+ """Convert snake_case string to PascalCase class name."""
25
+ components = re.split(r"[^a-zA-Z0-9]+", snake_str)
26
+ return "".join(x.capitalize() for x in components if x)
27
+
28
+
29
+ def _validate_and_split_name(
30
+ raw_name: str,
31
+ item_type: str = "File",
32
+ *,
33
+ allow_nested: bool = True,
34
+ ) -> tuple[list[str], str, str]:
35
+ """
36
+ Validate a generator name (optionally with nested '/' path segments) and return
37
+ (dir_segments, snake_name, class_name).
38
+ """
39
+ trimmed = raw_name.strip()
40
+ if not trimmed:
41
+ raise SystemExit(f"Invalid {item_type.lower()} name: {raw_name!r}")
42
+
43
+ if (
44
+ "\\" in trimmed
45
+ or trimmed.startswith("/")
46
+ or trimmed.endswith("/")
47
+ or "//" in trimmed
48
+ or Path(trimmed).is_absolute()
49
+ ):
50
+ raise SystemExit(
51
+ f"Invalid {item_type.lower()} path {raw_name!r}: absolute or malformed paths are not allowed."
52
+ )
53
+
54
+ segments = trimmed.split("/")
55
+ if not allow_nested and len(segments) > 1:
56
+ raise SystemExit(
57
+ f"Invalid {item_type.lower()} name {raw_name!r}: nested paths are not allowed."
58
+ )
59
+
60
+ for seg in segments:
61
+ if seg in ("", ".", ".."):
62
+ raise SystemExit(
63
+ f"Invalid {item_type.lower()} path {raw_name!r}: path traversal is not allowed."
64
+ )
65
+ seg_snake = snake_case(seg)
66
+ if not _SEGMENT_PATTERN.fullmatch(seg) or not _SEGMENT_PATTERN.fullmatch(
67
+ seg_snake
68
+ ):
69
+ raise SystemExit(
70
+ f"Invalid {item_type.lower()} name segment {seg!r} in {raw_name!r}. "
71
+ "Each segment must be a valid Python identifier starting with a letter or underscore."
72
+ )
73
+
74
+ base_name = segments[-1]
75
+ snake_name = snake_case(base_name)
76
+ class_name = _pascal_case(snake_name)
77
+ if not class_name or not _SEGMENT_PATTERN.fullmatch(class_name):
78
+ raise SystemExit(
79
+ f"Invalid {item_type.lower()} class name {class_name!r} derived from {raw_name!r}."
80
+ )
81
+
82
+ return segments[:-1], snake_name, class_name
83
+
84
+
85
+ _CREATE_PATTERNS = (
86
+ re.compile(r"^create_(\w+?)_table$"),
87
+ re.compile(r"^create_(\w+)$"),
88
+ )
89
+ _CHANGE_PATTERNS = (
90
+ re.compile(r".+_(?:to|from|in)_(\w+?)_table$"),
91
+ re.compile(r".+_(?:to|from|in)_(\w+)$"),
92
+ )
93
+
94
+
95
+ def _guess_table(name: str) -> tuple[str | None, bool]:
96
+ """Infer table name and whether migration creates a table from its name."""
97
+ for pattern in _CREATE_PATTERNS:
98
+ if match := pattern.match(name):
99
+ return match.group(1), True
100
+ for pattern in _CHANGE_PATTERNS:
101
+ if match := pattern.match(name):
102
+ return match.group(1), False
103
+ return None, False
104
+
105
+
106
+ _CREATE_STUB = get_stub("migration.create")
107
+ _ALTER_STUB = get_stub("migration.alter")
108
+ _BLANK_STUB = get_stub("migration.blank")
109
+
110
+
111
+ def _scaffold_file(
112
+ raw_name: str,
113
+ default_dir_rel: str,
114
+ stub_name: str,
115
+ override_path: str | None = None,
116
+ force: bool = False,
117
+ extra_replacements: dict[str, str] | None = None,
118
+ item_type: str = "File",
119
+ ) -> Path:
120
+ """
121
+ Generic scaffolding engine for SwiftPY generators.
122
+ Resolves class names, filenames, validates path containment, creates target directories,
123
+ and writes stub templates.
124
+ """
125
+ dir_segments, snake_name, class_name = _validate_and_split_name(
126
+ raw_name,
127
+ item_type=item_type,
128
+ allow_nested=True,
129
+ )
130
+
131
+ base_dir = Path(override_path) if override_path else Path.cwd() / default_dir_rel
132
+ resolved_base = base_dir.resolve()
133
+ target_dir = base_dir.joinpath(*dir_segments) if dir_segments else base_dir
134
+ filename = f"{snake_name}.py"
135
+ filepath = target_dir / filename
136
+
137
+ resolved_target_dir = target_dir.resolve()
138
+ resolved_filepath = filepath.resolve()
139
+ if not resolved_target_dir.is_relative_to(
140
+ resolved_base
141
+ ) or not resolved_filepath.is_relative_to(resolved_base):
142
+ raise SystemExit(
143
+ f"Invalid {item_type.lower()} path {raw_name!r}: target escapes base directory '{resolved_base}'."
144
+ )
145
+
146
+ target_dir.mkdir(parents=True, exist_ok=True)
147
+
148
+ # Re-verify containment after directory creation
149
+ if not filepath.resolve().is_relative_to(resolved_base) or filepath.is_symlink():
150
+ raise SystemExit(
151
+ f"Invalid {item_type.lower()} path {raw_name!r}: target escapes base directory '{resolved_base}'."
152
+ )
153
+
154
+ if filepath.exists() and not force:
155
+ raise SystemExit(f"{item_type} already exists: {filepath}. Use --force to overwrite.")
156
+
157
+ stub = get_stub(stub_name)
158
+ content = stub.replace("{class_name}", class_name)
159
+ if extra_replacements:
160
+ for k, v in extra_replacements.items():
161
+ content = content.replace(f"{{{k}}}", v)
162
+
163
+ filepath.write_text(content, encoding="utf-8")
164
+
165
+ display_name = str(filepath.relative_to(Path.cwd())) if filepath.is_relative_to(Path.cwd()) else str(filepath)
166
+ if sys.stdout.isatty() and "NO_COLOR" not in os.environ:
167
+ print(f"\033[32mCreated {item_type}:\033[0m {display_name}")
168
+ else:
169
+ print(f"Created {item_type}: {display_name}")
170
+
171
+ return filepath
172
+
173
+
174
+ class MakeCommand:
175
+ """Command dispatcher for code generation scaffolding."""
176
+
177
+ @staticmethod
178
+ def register(
179
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
180
+ ) -> None:
181
+ # 1. make:migration
182
+ parser_mig = subparsers.add_parser(
183
+ "make:migration",
184
+ help="Create a new migration file",
185
+ )
186
+ parser_mig.add_argument(
187
+ "name",
188
+ help="The name of the migration (e.g., create_users_table)",
189
+ )
190
+ parser_mig.add_argument(
191
+ "--path",
192
+ dest="path",
193
+ default=None,
194
+ help="The directory where the migration should be saved",
195
+ )
196
+ parser_mig.add_argument(
197
+ "--create",
198
+ dest="create",
199
+ default=None,
200
+ help="The table to be created",
201
+ )
202
+ parser_mig.add_argument(
203
+ "--table",
204
+ dest="table",
205
+ default=None,
206
+ help="The table to be altered",
207
+ )
208
+ parser_mig.add_argument(
209
+ "--force",
210
+ action="store_true",
211
+ help="Overwrite the migration if a file with the same name already exists",
212
+ )
213
+ parser_mig.set_defaults(
214
+ _handler=MakeCommand.handle_migration,
215
+ )
216
+
217
+ # 2. make:model
218
+ parser_model = subparsers.add_parser(
219
+ "make:model",
220
+ help="Create a new Model class",
221
+ )
222
+ parser_model.add_argument(
223
+ "name",
224
+ help="The name of the model (e.g., User)",
225
+ )
226
+ parser_model.add_argument(
227
+ "-m",
228
+ "--migration",
229
+ action="store_true",
230
+ help="Create a new migration file for the model",
231
+ )
232
+ parser_model.add_argument(
233
+ "--table",
234
+ dest="table",
235
+ default=None,
236
+ help="The table associated with the model",
237
+ )
238
+ parser_model.add_argument(
239
+ "--path",
240
+ dest="path",
241
+ default=None,
242
+ help="The directory where the model should be saved (defaults to app/models)",
243
+ )
244
+ parser_model.add_argument(
245
+ "--migration-path",
246
+ dest="migration_path",
247
+ default=None,
248
+ help="The directory where the generated migration should be saved (defaults to database/migrations)",
249
+ )
250
+ parser_model.add_argument(
251
+ "--force",
252
+ action="store_true",
253
+ help="Overwrite the model if it already exists",
254
+ )
255
+ parser_model.set_defaults(
256
+ _handler=MakeCommand.handle_model,
257
+ )
258
+
259
+ # 3. make:service
260
+ parser_service = subparsers.add_parser(
261
+ "make:service",
262
+ help="Create a new Service class",
263
+ )
264
+ parser_service.add_argument(
265
+ "name",
266
+ help="The name of the service (e.g., PaymentService)",
267
+ )
268
+ parser_service.add_argument(
269
+ "--path",
270
+ dest="path",
271
+ default=None,
272
+ help="The directory where the service should be saved (defaults to app/services)",
273
+ )
274
+ parser_service.add_argument(
275
+ "--force",
276
+ action="store_true",
277
+ help="Overwrite the service if it already exists",
278
+ )
279
+ parser_service.set_defaults(
280
+ _handler=MakeCommand.handle_service,
281
+ )
282
+
283
+ # 4. make:request
284
+ parser_request = subparsers.add_parser(
285
+ "make:request",
286
+ help="Create a new FormRequest validation class",
287
+ )
288
+ parser_request.add_argument(
289
+ "name",
290
+ help="The name of the request (e.g., CreateUserRequest)",
291
+ )
292
+ parser_request.add_argument(
293
+ "--path",
294
+ dest="path",
295
+ default=None,
296
+ help="The directory where the request should be saved (defaults to app/http/requests)",
297
+ )
298
+ parser_request.add_argument(
299
+ "--force",
300
+ action="store_true",
301
+ help="Overwrite the request if it already exists",
302
+ )
303
+ parser_request.set_defaults(
304
+ _handler=MakeCommand.handle_request,
305
+ )
306
+
307
+ # 5. make:resource
308
+ parser_resource = subparsers.add_parser(
309
+ "make:resource",
310
+ help="Create a new Resource transformer class",
311
+ )
312
+ parser_resource.add_argument(
313
+ "name",
314
+ help="The name of the resource (e.g., UserResource)",
315
+ )
316
+ parser_resource.add_argument(
317
+ "--path",
318
+ dest="path",
319
+ default=None,
320
+ help="The directory where the resource should be saved (defaults to app/http/resources)",
321
+ )
322
+ parser_resource.add_argument(
323
+ "--force",
324
+ action="store_true",
325
+ help="Overwrite the resource if it already exists",
326
+ )
327
+ parser_resource.set_defaults(
328
+ _handler=MakeCommand.handle_resource,
329
+ )
330
+
331
+ # 6. make:repository
332
+ parser_repo = subparsers.add_parser(
333
+ "make:repository",
334
+ help="Create a new Repository class",
335
+ )
336
+ parser_repo.add_argument(
337
+ "name",
338
+ help="The name of the repository (e.g., UserRepository)",
339
+ )
340
+ parser_repo.add_argument(
341
+ "--path",
342
+ dest="path",
343
+ default=None,
344
+ help="The directory where the repository should be saved (defaults to app/repositories)",
345
+ )
346
+ parser_repo.add_argument(
347
+ "--force",
348
+ action="store_true",
349
+ help="Overwrite the repository if it already exists",
350
+ )
351
+ parser_repo.set_defaults(
352
+ _handler=MakeCommand.handle_repository,
353
+ )
354
+
355
+ # 7. make:schema
356
+ parser_schema = subparsers.add_parser(
357
+ "make:schema",
358
+ help="Create a new Schema class",
359
+ )
360
+ parser_schema.add_argument(
361
+ "name",
362
+ help="The name of the schema (e.g., OrderSchema)",
363
+ )
364
+ parser_schema.add_argument(
365
+ "--path",
366
+ dest="path",
367
+ default=None,
368
+ help="The directory where the schema should be saved (defaults to app/schemas)",
369
+ )
370
+ parser_schema.add_argument(
371
+ "--force",
372
+ action="store_true",
373
+ help="Overwrite the schema if it already exists",
374
+ )
375
+ parser_schema.set_defaults(
376
+ _handler=MakeCommand.handle_schema,
377
+ )
378
+
379
+ # 8. make:mail
380
+ parser_mail = subparsers.add_parser(
381
+ "make:mail",
382
+ help="Create a new Mailable class",
383
+ )
384
+ parser_mail.add_argument(
385
+ "name",
386
+ help="The name of the mailable (e.g., WelcomeEmail)",
387
+ )
388
+ parser_mail.add_argument(
389
+ "--path",
390
+ dest="path",
391
+ default=None,
392
+ help="The directory where the mailable should be saved (defaults to app/mail)",
393
+ )
394
+ parser_mail.add_argument(
395
+ "--force",
396
+ action="store_true",
397
+ help="Overwrite the mailable if it already exists",
398
+ )
399
+ parser_mail.set_defaults(
400
+ _handler=MakeCommand.handle_mail,
401
+ )
402
+
403
+ # 9. make:api-key
404
+ parser_api_key = subparsers.add_parser(
405
+ "make:api-key",
406
+ help="Generate a new prefix.secret API key and its peppered HMAC-SHA256 hash",
407
+ )
408
+ parser_api_key.add_argument(
409
+ "--prefix",
410
+ dest="prefix",
411
+ default="sk_live_",
412
+ help="Prefix for the API key (default: sk_live_)",
413
+ )
414
+ parser_api_key.add_argument(
415
+ "--pepper",
416
+ dest="pepper",
417
+ default=None,
418
+ help="Server pepper for HMAC-SHA256 hashing (defaults to AUTH_KEY_PEPPER config)",
419
+ )
420
+ parser_api_key.add_argument(
421
+ "-m",
422
+ "--migration",
423
+ action="store_true",
424
+ help="Also create the api_keys table migration file",
425
+ )
426
+ parser_api_key.add_argument(
427
+ "--path",
428
+ dest="path",
429
+ default=None,
430
+ help="Migration target directory when --migration is specified",
431
+ )
432
+ parser_api_key.add_argument(
433
+ "--force",
434
+ action="store_true",
435
+ help="Overwrite the generated migration if it already exists",
436
+ )
437
+ parser_api_key.set_defaults(
438
+ _handler=MakeCommand.handle_api_key,
439
+ )
440
+
441
+ # 10. make:controller
442
+ parser_controller = subparsers.add_parser(
443
+ "make:controller",
444
+ help="Create a new Controller class",
445
+ )
446
+ parser_controller.add_argument(
447
+ "name",
448
+ help="The name of the controller (e.g., UserController)",
449
+ )
450
+ parser_controller.add_argument(
451
+ "--path",
452
+ dest="path",
453
+ default=None,
454
+ help="The directory where the controller should be saved (defaults to app/http/controllers)",
455
+ )
456
+ parser_controller.add_argument(
457
+ "--force",
458
+ action="store_true",
459
+ help="Overwrite the controller if it already exists",
460
+ )
461
+ parser_controller.set_defaults(
462
+ _handler=MakeCommand.handle_controller,
463
+ )
464
+
465
+ # 11. make:middleware
466
+ parser_middleware = subparsers.add_parser(
467
+ "make:middleware",
468
+ help="Create a new ASGI Middleware class",
469
+ )
470
+ parser_middleware.add_argument(
471
+ "name",
472
+ help="The name of the middleware (e.g., AuthMiddleware)",
473
+ )
474
+ parser_middleware.add_argument(
475
+ "--path",
476
+ dest="path",
477
+ default=None,
478
+ help="The directory where the middleware should be saved (defaults to app/http/middleware)",
479
+ )
480
+ parser_middleware.add_argument(
481
+ "--force",
482
+ action="store_true",
483
+ help="Overwrite the middleware if it already exists",
484
+ )
485
+ parser_middleware.set_defaults(
486
+ _handler=MakeCommand.handle_middleware,
487
+ )
488
+
489
+ # 12. make:job
490
+ parser_job = subparsers.add_parser(
491
+ "make:job",
492
+ help="Create a new queued background Job class",
493
+ )
494
+ parser_job.add_argument(
495
+ "name",
496
+ help="The name of the job (e.g., SendNotificationJob)",
497
+ )
498
+ parser_job.add_argument(
499
+ "--path",
500
+ dest="path",
501
+ default=None,
502
+ help="The directory where the job should be saved (defaults to app/jobs)",
503
+ )
504
+ parser_job.add_argument(
505
+ "--force",
506
+ action="store_true",
507
+ help="Overwrite the job if it already exists",
508
+ )
509
+ parser_job.set_defaults(
510
+ _handler=MakeCommand.handle_job,
511
+ )
512
+
513
+ # 13. make:event
514
+ parser_event = subparsers.add_parser(
515
+ "make:event",
516
+ help="Create a new domain Event dataclass",
517
+ )
518
+ parser_event.add_argument(
519
+ "name",
520
+ help="The name of the event (e.g., UserRegistered)",
521
+ )
522
+ parser_event.add_argument(
523
+ "--path",
524
+ dest="path",
525
+ default=None,
526
+ help="The directory where the event should be saved (defaults to app/events)",
527
+ )
528
+ parser_event.add_argument(
529
+ "--force",
530
+ action="store_true",
531
+ help="Overwrite the event if it already exists",
532
+ )
533
+ parser_event.set_defaults(
534
+ _handler=MakeCommand.handle_event,
535
+ )
536
+
537
+ # 14. make:listener
538
+ parser_listener = subparsers.add_parser(
539
+ "make:listener",
540
+ help="Create a new event Listener class",
541
+ )
542
+ parser_listener.add_argument(
543
+ "name",
544
+ help="The name of the listener (e.g., SendWelcomeNotification)",
545
+ )
546
+ parser_listener.add_argument(
547
+ "--path",
548
+ dest="path",
549
+ default=None,
550
+ help="The directory where the listener should be saved (defaults to app/listeners)",
551
+ )
552
+ parser_listener.add_argument(
553
+ "--force",
554
+ action="store_true",
555
+ help="Overwrite the listener if it already exists",
556
+ )
557
+ parser_listener.set_defaults(
558
+ _handler=MakeCommand.handle_listener,
559
+ )
560
+
561
+ # 15. make:provider
562
+ parser_provider = subparsers.add_parser(
563
+ "make:provider",
564
+ help="Create a new ServiceProvider class",
565
+ )
566
+ parser_provider.add_argument(
567
+ "name",
568
+ help="The name of the service provider (e.g., AppServiceProvider)",
569
+ )
570
+ parser_provider.add_argument(
571
+ "--path",
572
+ dest="path",
573
+ default=None,
574
+ help="The directory where the provider should be saved (defaults to app/providers)",
575
+ )
576
+ parser_provider.add_argument(
577
+ "--force",
578
+ action="store_true",
579
+ help="Overwrite the provider if it already exists",
580
+ )
581
+ parser_provider.set_defaults(
582
+ _handler=MakeCommand.handle_provider,
583
+ )
584
+
585
+ # 16. make:seeder
586
+ parser_seeder = subparsers.add_parser(
587
+ "make:seeder",
588
+ help="Create a new database Seeder class",
589
+ )
590
+ parser_seeder.add_argument(
591
+ "name",
592
+ help="The name of the seeder (e.g., UserSeeder)",
593
+ )
594
+ parser_seeder.add_argument(
595
+ "--path",
596
+ dest="path",
597
+ default=None,
598
+ help="The directory where the seeder should be saved (defaults to database/seeders)",
599
+ )
600
+ parser_seeder.add_argument(
601
+ "--force",
602
+ action="store_true",
603
+ help="Overwrite the seeder if it already exists",
604
+ )
605
+ parser_seeder.set_defaults(
606
+ _handler=MakeCommand.handle_seeder,
607
+ )
608
+
609
+ @staticmethod
610
+ def handle_migration(args: argparse.Namespace) -> None:
611
+ """Handle make:migration command execution."""
612
+ raw_name: str = args.name.strip()
613
+ if not raw_name or "/" in raw_name or "\\" in raw_name or ".." in raw_name:
614
+ raise SystemExit(f"Invalid migration name: {raw_name!r}")
615
+
616
+ snake_name = re.sub(r"[^a-zA-Z0-9_]+", "_", raw_name.lower()).strip("_")
617
+ if not snake_name or not _SEGMENT_PATTERN.fullmatch(snake_name):
618
+ raise SystemExit(f"Invalid migration name: {raw_name!r}")
619
+
620
+ class_name = _pascal_case(snake_name)
621
+ if not class_name or not _SEGMENT_PATTERN.fullmatch(class_name):
622
+ raise SystemExit(f"Invalid migration class name: {class_name!r}")
623
+
624
+ raw_table = getattr(args, "create", None) or getattr(args, "table", None)
625
+ is_create = bool(getattr(args, "create", None))
626
+ if raw_table is not None:
627
+ try:
628
+ table_name: str | None = validate_identifier(
629
+ str(raw_table), param_name="table"
630
+ )
631
+ except ValueError as exc:
632
+ raise SystemExit(f"Invalid table identifier: {exc}") from exc
633
+ else:
634
+ guessed_table, is_create = _guess_table(snake_name)
635
+ if guessed_table is not None:
636
+ try:
637
+ table_name = validate_identifier(
638
+ guessed_table, param_name="table"
639
+ )
640
+ except ValueError as exc:
641
+ raise SystemExit(f"Invalid table identifier: {exc}") from exc
642
+ else:
643
+ table_name = None
644
+
645
+ target_dir = (
646
+ Path(args.path) if args.path else Path.cwd() / "database" / "migrations"
647
+ )
648
+ resolved_base = target_dir.resolve()
649
+ timestamp = datetime.now(tz=UTC).strftime("%Y_%m_%d_%H%M%S")
650
+ filename = f"{timestamp}_{snake_name}.py"
651
+ filepath = target_dir / filename
652
+
653
+ if not filepath.resolve().is_relative_to(resolved_base):
654
+ raise SystemExit(
655
+ f"Invalid migration path {raw_name!r}: target escapes base directory '{resolved_base}'."
656
+ )
657
+
658
+ target_dir.mkdir(parents=True, exist_ok=True)
659
+ if not filepath.resolve().is_relative_to(resolved_base) or filepath.is_symlink():
660
+ raise SystemExit(
661
+ f"Invalid migration path {raw_name!r}: target escapes base directory '{resolved_base}'."
662
+ )
663
+
664
+ if filepath.exists() and not getattr(args, "force", False):
665
+ raise SystemExit(
666
+ f"Migration already exists: {filepath}. Use --force to overwrite."
667
+ )
668
+
669
+ stub_override = getattr(args, "stub", None)
670
+ if stub_override:
671
+ content = (
672
+ get_stub(stub_override)
673
+ .replace("{class_name}", class_name)
674
+ .replace("{table_name}", table_name or "api_keys")
675
+ )
676
+ elif table_name is None:
677
+ content = get_stub("migration.blank").replace("{class_name}", class_name)
678
+ elif is_create and table_name == "api_keys":
679
+ content = (
680
+ get_stub("migration.api_keys")
681
+ .replace("{class_name}", class_name)
682
+ .replace("{table_name}", table_name)
683
+ )
684
+ elif is_create:
685
+ content = (
686
+ get_stub("migration.create")
687
+ .replace("{class_name}", class_name)
688
+ .replace("{table_name}", table_name)
689
+ )
690
+ else:
691
+ content = (
692
+ get_stub("migration.alter")
693
+ .replace("{class_name}", class_name)
694
+ .replace("{table_name}", table_name)
695
+ )
696
+
697
+ filepath.write_text(content, encoding="utf-8")
698
+ if sys.stdout.isatty() and "NO_COLOR" not in os.environ:
699
+ print(f"\033[32mCreated Migration:\033[0m {filename}")
700
+ else:
701
+ print(f"Created Migration: {filename}")
702
+
703
+ @staticmethod
704
+ def handle_model(args: argparse.Namespace) -> None:
705
+ """Handle make:model command execution."""
706
+ raw_name: str = args.name.strip()
707
+ _, snake_name, _ = _validate_and_split_name(
708
+ raw_name,
709
+ item_type="Model",
710
+ allow_nested=True,
711
+ )
712
+
713
+ raw_table = getattr(args, "table", None)
714
+ if raw_table is not None:
715
+ try:
716
+ table_name = validate_identifier(str(raw_table), param_name="table")
717
+ except ValueError as exc:
718
+ raise SystemExit(f"Invalid table identifier: {exc}") from exc
719
+ else:
720
+ try:
721
+ table_name = validate_identifier(
722
+ pluralize(snake_name), param_name="table"
723
+ )
724
+ except ValueError as exc:
725
+ raise SystemExit(f"Invalid table identifier: {exc}") from exc
726
+
727
+ _scaffold_file(
728
+ raw_name=raw_name,
729
+ default_dir_rel="app/models",
730
+ stub_name="model",
731
+ override_path=getattr(args, "path", None),
732
+ force=bool(getattr(args, "force", False)),
733
+ extra_replacements={"table_name": table_name},
734
+ item_type="Model",
735
+ )
736
+
737
+ if getattr(args, "migration", False):
738
+ # If -m is added, create migration file for that model
739
+ mig_name = f"create_{table_name}_table"
740
+ mig_args = argparse.Namespace(
741
+ name=mig_name,
742
+ path=getattr(args, "migration_path", None),
743
+ create=table_name,
744
+ table=None,
745
+ force=bool(getattr(args, "force", False)),
746
+ )
747
+ MakeCommand.handle_migration(mig_args)
748
+
749
+ @staticmethod
750
+ def handle_api_key(args: argparse.Namespace) -> None:
751
+ """Handle make:api-key command execution (P2-A2 requirement 5)."""
752
+ prefix_arg: str = getattr(args, "prefix", "sk_live_") or "sk_live_"
753
+ pepper_arg: str | None = getattr(args, "pepper", None)
754
+
755
+ try:
756
+ active_pepper: str | bytes = (
757
+ pepper_arg if pepper_arg is not None else get_server_pepper()
758
+ )
759
+ except ConfigurationError as exc:
760
+ raise SystemExit(str(exc)) from exc
761
+
762
+ full_key, key_prefix, secret = generate_api_key(prefix=prefix_arg)
763
+ key_hash = hash_api_key(secret, pepper=active_pepper)
764
+
765
+ print("Generated API Key (save this secret now; it will not be shown again):")
766
+ print(f" API Key: {full_key}")
767
+ print(f" key_prefix: {key_prefix}")
768
+ print(f" key_hash: {key_hash}")
769
+
770
+ if getattr(args, "migration", False):
771
+ mig_args = argparse.Namespace(
772
+ name="create_api_keys_table",
773
+ path=getattr(args, "path", None),
774
+ create="api_keys",
775
+ table=None,
776
+ stub="migration.api_keys",
777
+ force=bool(getattr(args, "force", False)),
778
+ )
779
+ MakeCommand.handle_migration(mig_args)
780
+
781
+ @staticmethod
782
+ def handle_service(args: argparse.Namespace) -> None:
783
+ """Handle make:service command execution."""
784
+ _scaffold_file(
785
+ raw_name=args.name,
786
+ default_dir_rel="app/services",
787
+ stub_name="service",
788
+ override_path=getattr(args, "path", None),
789
+ force=bool(getattr(args, "force", False)),
790
+ item_type="Service",
791
+ )
792
+
793
+ @staticmethod
794
+ def handle_request(args: argparse.Namespace) -> None:
795
+ """Handle make:request command execution."""
796
+ _scaffold_file(
797
+ raw_name=args.name,
798
+ default_dir_rel="app/http/requests",
799
+ stub_name="request",
800
+ override_path=getattr(args, "path", None),
801
+ force=bool(getattr(args, "force", False)),
802
+ item_type="Request",
803
+ )
804
+
805
+ @staticmethod
806
+ def handle_resource(args: argparse.Namespace) -> None:
807
+ """Handle make:resource command execution."""
808
+ _scaffold_file(
809
+ raw_name=args.name,
810
+ default_dir_rel="app/http/resources",
811
+ stub_name="resource",
812
+ override_path=getattr(args, "path", None),
813
+ force=bool(getattr(args, "force", False)),
814
+ item_type="Resource",
815
+ )
816
+
817
+ @staticmethod
818
+ def handle_repository(args: argparse.Namespace) -> None:
819
+ """Handle make:repository command execution."""
820
+ _scaffold_file(
821
+ raw_name=args.name,
822
+ default_dir_rel="app/repositories",
823
+ stub_name="repository",
824
+ override_path=getattr(args, "path", None),
825
+ force=bool(getattr(args, "force", False)),
826
+ item_type="Repository",
827
+ )
828
+
829
+ @staticmethod
830
+ def handle_schema(args: argparse.Namespace) -> None:
831
+ """Handle make:schema command execution."""
832
+ _scaffold_file(
833
+ raw_name=args.name,
834
+ default_dir_rel="app/schemas",
835
+ stub_name="schema",
836
+ override_path=getattr(args, "path", None),
837
+ force=bool(getattr(args, "force", False)),
838
+ item_type="Schema",
839
+ )
840
+
841
+ @staticmethod
842
+ def handle_mail(args: argparse.Namespace) -> None:
843
+ """Handle make:mail command execution."""
844
+ _scaffold_file(
845
+ raw_name=args.name,
846
+ default_dir_rel="app/mail",
847
+ stub_name="mail",
848
+ override_path=getattr(args, "path", None),
849
+ force=bool(getattr(args, "force", False)),
850
+ item_type="Mailable",
851
+ )
852
+
853
+ @staticmethod
854
+ def handle_controller(args: argparse.Namespace) -> None:
855
+ """Handle make:controller command execution."""
856
+ _scaffold_file(
857
+ raw_name=args.name,
858
+ default_dir_rel="app/http/controllers",
859
+ stub_name="controller",
860
+ override_path=getattr(args, "path", None),
861
+ force=bool(getattr(args, "force", False)),
862
+ item_type="Controller",
863
+ )
864
+
865
+ @staticmethod
866
+ def handle_middleware(args: argparse.Namespace) -> None:
867
+ """Handle make:middleware command execution."""
868
+ _scaffold_file(
869
+ raw_name=args.name,
870
+ default_dir_rel="app/http/middleware",
871
+ stub_name="middleware",
872
+ override_path=getattr(args, "path", None),
873
+ force=bool(getattr(args, "force", False)),
874
+ item_type="Middleware",
875
+ )
876
+
877
+ @staticmethod
878
+ def handle_job(args: argparse.Namespace) -> None:
879
+ """Handle make:job command execution."""
880
+ _scaffold_file(
881
+ raw_name=args.name,
882
+ default_dir_rel="app/jobs",
883
+ stub_name="job",
884
+ override_path=getattr(args, "path", None),
885
+ force=bool(getattr(args, "force", False)),
886
+ item_type="Job",
887
+ )
888
+
889
+ @staticmethod
890
+ def handle_event(args: argparse.Namespace) -> None:
891
+ """Handle make:event command execution."""
892
+ _scaffold_file(
893
+ raw_name=args.name,
894
+ default_dir_rel="app/events",
895
+ stub_name="event",
896
+ override_path=getattr(args, "path", None),
897
+ force=bool(getattr(args, "force", False)),
898
+ item_type="Event",
899
+ )
900
+
901
+ @staticmethod
902
+ def handle_listener(args: argparse.Namespace) -> None:
903
+ """Handle make:listener command execution."""
904
+ _scaffold_file(
905
+ raw_name=args.name,
906
+ default_dir_rel="app/listeners",
907
+ stub_name="listener",
908
+ override_path=getattr(args, "path", None),
909
+ force=bool(getattr(args, "force", False)),
910
+ item_type="Listener",
911
+ )
912
+
913
+ @staticmethod
914
+ def handle_provider(args: argparse.Namespace) -> None:
915
+ """Handle make:provider command execution."""
916
+ _scaffold_file(
917
+ raw_name=args.name,
918
+ default_dir_rel="app/providers",
919
+ stub_name="provider",
920
+ override_path=getattr(args, "path", None),
921
+ force=bool(getattr(args, "force", False)),
922
+ item_type="Provider",
923
+ )
924
+
925
+ @staticmethod
926
+ def handle_seeder(args: argparse.Namespace) -> None:
927
+ """Handle make:seeder command execution."""
928
+ _scaffold_file(
929
+ raw_name=args.name,
930
+ default_dir_rel="database/seeders",
931
+ stub_name="seeder",
932
+ override_path=getattr(args, "path", None),
933
+ force=bool(getattr(args, "force", False)),
934
+ item_type="Seeder",
935
+ )