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,306 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import concurrent.futures
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from swiftpy.console.loader import load_application
11
+ from swiftpy.core.config import ConfigurationError
12
+ from swiftpy.queue.dispatcher import QueueDispatcher
13
+ from swiftpy.queue.worker import QueueWorker
14
+
15
+ if TYPE_CHECKING:
16
+ from swiftpy.core.application import Application
17
+
18
+ logger = logging.getLogger("swiftpy.console")
19
+
20
+
21
+ def _load_optional_app(app_target: str | None) -> Application | None:
22
+ """Load the optional `module:attribute` application target or exit on failure."""
23
+ if not app_target:
24
+ return None
25
+ try:
26
+ return load_application(app_target)
27
+ except (
28
+ ImportError,
29
+ AttributeError,
30
+ TypeError,
31
+ ConfigurationError,
32
+ OSError,
33
+ ValueError,
34
+ RuntimeError,
35
+ SyntaxError,
36
+ ) as exc:
37
+ logger.error("Could not boot application target '%s': %s", app_target, exc)
38
+ raise SystemExit(1) from exc
39
+
40
+
41
+ def _run_queue_action(
42
+ action: Callable[[], Awaitable[None]],
43
+ *,
44
+ app: Application | None = None,
45
+ temporary_dispatcher: QueueDispatcher | None = None,
46
+ ) -> None:
47
+ """Execute an async queue CLI action within optional app lifespan and dispatcher cleanup."""
48
+
49
+ async def _execute() -> None:
50
+ if app is not None:
51
+ await app.startup()
52
+ try:
53
+ await action()
54
+ finally:
55
+ if temporary_dispatcher is not None:
56
+ await temporary_dispatcher.close()
57
+ if app is not None:
58
+ await app.shutdown()
59
+
60
+ _run_coroutine(_execute())
61
+
62
+
63
+ def _run_coroutine(coro: Any) -> Any:
64
+ """Run an async coroutine synchronously, safely handling existing active event loops."""
65
+ try:
66
+ asyncio.get_running_loop()
67
+ except RuntimeError:
68
+ return asyncio.run(coro)
69
+
70
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
71
+ return executor.submit(asyncio.run, coro).result()
72
+
73
+
74
+ class QueueCommand:
75
+ """Console commands for managing and executing queue workers."""
76
+
77
+ @staticmethod
78
+ def register(
79
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
80
+ ) -> None:
81
+ parser = subparsers.add_parser(
82
+ "queue:work",
83
+ help="Start processing jobs on the queue as a worker process",
84
+ )
85
+ parser.add_argument(
86
+ "--queue",
87
+ default="default",
88
+ help="The names of the queues to work (comma-separated)",
89
+ )
90
+ parser.add_argument(
91
+ "--sleep",
92
+ type=float,
93
+ default=1.0,
94
+ help="Number of seconds to sleep when no job is available",
95
+ )
96
+ parser.add_argument(
97
+ "--max-jobs",
98
+ type=int,
99
+ default=None,
100
+ help="The maximum number of jobs that the worker may process before exiting",
101
+ )
102
+ parser.add_argument(
103
+ "--stop-when-empty",
104
+ action="store_true",
105
+ help="Stop processing jobs when all queues are empty",
106
+ )
107
+ parser.add_argument(
108
+ "--timeout",
109
+ type=float,
110
+ default=None,
111
+ help="The number of seconds a child job can run before timing out",
112
+ )
113
+ parser.add_argument(
114
+ "--concurrency",
115
+ type=int,
116
+ default=1,
117
+ help="Number of concurrent worker tasks to process jobs simultaneously",
118
+ )
119
+ parser.add_argument(
120
+ "--prefix",
121
+ default=None,
122
+ help="The Redis key prefix for queues",
123
+ )
124
+ parser.add_argument(
125
+ "--app",
126
+ default=None,
127
+ help="Application target (module:attribute) to boot before processing",
128
+ )
129
+
130
+ parser.set_defaults(
131
+ _handler=QueueCommand.handle_work,
132
+ )
133
+
134
+ # queue:failed
135
+ failed_parser = subparsers.add_parser(
136
+ "queue:failed",
137
+ help="List failed jobs recorded in the Dead Letter Queue",
138
+ )
139
+ failed_parser.add_argument(
140
+ "--queue",
141
+ default="default",
142
+ help="The queue to inspect",
143
+ )
144
+ failed_parser.add_argument(
145
+ "--limit",
146
+ type=int,
147
+ default=50,
148
+ help="Maximum number of failed records to list",
149
+ )
150
+ failed_parser.add_argument(
151
+ "--prefix",
152
+ default=None,
153
+ help="The Redis key prefix for queues",
154
+ )
155
+ failed_parser.add_argument(
156
+ "--app",
157
+ default=None,
158
+ help="Application target (module:attribute) to boot before running command",
159
+ )
160
+ failed_parser.set_defaults(
161
+ _handler=QueueCommand.handle_failed,
162
+ )
163
+
164
+ # queue:retry
165
+ retry_parser = subparsers.add_parser(
166
+ "queue:retry",
167
+ help="Retry failed jobs by pushing them back to the ready queue",
168
+ )
169
+ retry_parser.add_argument(
170
+ "id",
171
+ help="The ID of the failed job to retry, or 'all' to retry all failed jobs",
172
+ )
173
+ retry_parser.add_argument(
174
+ "--queue",
175
+ default="default",
176
+ help="The queue to retry failed jobs on",
177
+ )
178
+ retry_parser.add_argument(
179
+ "--prefix",
180
+ default=None,
181
+ help="The Redis key prefix for queues",
182
+ )
183
+ retry_parser.add_argument(
184
+ "--app",
185
+ default=None,
186
+ help="Application target (module:attribute) to boot before running command",
187
+ )
188
+ retry_parser.set_defaults(
189
+ _handler=QueueCommand.handle_retry,
190
+ )
191
+
192
+ # queue:flush
193
+ flush_parser = subparsers.add_parser(
194
+ "queue:flush",
195
+ help="Flush all failed jobs from the Dead Letter Queue",
196
+ )
197
+ flush_parser.add_argument(
198
+ "--queue",
199
+ default="default",
200
+ help="The queue to flush failed jobs from",
201
+ )
202
+ flush_parser.add_argument(
203
+ "--prefix",
204
+ default=None,
205
+ help="The Redis key prefix for queues",
206
+ )
207
+ flush_parser.add_argument(
208
+ "--app",
209
+ default=None,
210
+ help="Application target (module:attribute) to boot before running command",
211
+ )
212
+ flush_parser.set_defaults(
213
+ _handler=QueueCommand.handle_flush,
214
+ )
215
+
216
+ @staticmethod
217
+ def handle_work(args: argparse.Namespace) -> None:
218
+ """Run the queue worker process."""
219
+ app = _load_optional_app(getattr(args, "app", None))
220
+ temporary_dispatcher = QueueDispatcher(prefix=args.prefix) if args.prefix else None
221
+ worker = QueueWorker(
222
+ queues=args.queue,
223
+ dispatcher=temporary_dispatcher,
224
+ sleep=args.sleep,
225
+ max_jobs=args.max_jobs,
226
+ stop_when_empty=args.stop_when_empty,
227
+ timeout=args.timeout,
228
+ concurrency=getattr(args, "concurrency", 1),
229
+ )
230
+ _run_queue_action(
231
+ worker.work,
232
+ app=app,
233
+ temporary_dispatcher=temporary_dispatcher,
234
+ )
235
+
236
+ @staticmethod
237
+ def handle_failed(args: argparse.Namespace) -> None:
238
+ """List failed jobs from the Dead Letter Queue."""
239
+ if args.limit < 1 or args.limit > 1000:
240
+ raise SystemExit(
241
+ f"Invalid --limit {args.limit}: must be between 1 and 1000."
242
+ )
243
+
244
+ app = _load_optional_app(getattr(args, "app", None))
245
+ temporary_dispatcher = QueueDispatcher(prefix=args.prefix) if args.prefix else None
246
+ dispatcher = temporary_dispatcher or QueueDispatcher.get_instance()
247
+
248
+ async def _list() -> None:
249
+ records = await dispatcher.get_failed_jobs(
250
+ queue=args.queue, start=0, stop=args.limit - 1
251
+ )
252
+ if not records:
253
+ print(f"No failed jobs found for queue '{args.queue}'.")
254
+ return
255
+
256
+ print(f"Failed jobs for queue '{args.queue}' ({len(records)}):")
257
+ for rec in records:
258
+ job_id = rec.get("job_id", "unknown")
259
+ job_class = rec.get("job_class", "unknown")
260
+ exc_info = rec.get("exception", {})
261
+ exc_msg = exc_info.get("message", "unknown error")
262
+ failed_at = rec.get("failed_at", 0)
263
+ print(f" [{job_id}] {job_class} - {exc_msg} (failed at {failed_at})")
264
+
265
+ _run_queue_action(
266
+ _list,
267
+ app=app,
268
+ temporary_dispatcher=temporary_dispatcher,
269
+ )
270
+
271
+ @staticmethod
272
+ def handle_retry(args: argparse.Namespace) -> None:
273
+ """Retry one or all failed jobs."""
274
+ app = _load_optional_app(getattr(args, "app", None))
275
+ temporary_dispatcher = QueueDispatcher(prefix=args.prefix) if args.prefix else None
276
+ dispatcher = temporary_dispatcher or QueueDispatcher.get_instance()
277
+
278
+ async def _retry() -> None:
279
+ target_id = None if args.id == "all" else args.id
280
+ retried = await dispatcher.retry_failed(
281
+ queue=args.queue, job_id=target_id
282
+ )
283
+ print(f"Retried {retried} failed job(s) on queue '{args.queue}'.")
284
+
285
+ _run_queue_action(
286
+ _retry,
287
+ app=app,
288
+ temporary_dispatcher=temporary_dispatcher,
289
+ )
290
+
291
+ @staticmethod
292
+ def handle_flush(args: argparse.Namespace) -> None:
293
+ """Flush failed jobs from the Dead Letter Queue."""
294
+ app = _load_optional_app(getattr(args, "app", None))
295
+ temporary_dispatcher = QueueDispatcher(prefix=args.prefix) if args.prefix else None
296
+ dispatcher = temporary_dispatcher or QueueDispatcher.get_instance()
297
+
298
+ async def _flush() -> None:
299
+ cleared = await dispatcher.flush_failed(queue=args.queue)
300
+ print(f"Flushed {cleared} failed job(s) from queue '{args.queue}'.")
301
+
302
+ _run_queue_action(
303
+ _flush,
304
+ app=app,
305
+ temporary_dispatcher=temporary_dispatcher,
306
+ )
@@ -0,0 +1,252 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ast
5
+ import asyncio
6
+ import concurrent.futures
7
+ import importlib
8
+ import importlib.util
9
+ import inspect
10
+ import os
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+ from typing import Any
16
+
17
+ from swiftpy.console.loader import load_application
18
+ from swiftpy.core.config import env
19
+ from swiftpy.database.pool import DatabasePool
20
+ from swiftpy.database.seeder import Seeder
21
+ from swiftpy.database.transaction import db
22
+ from swiftpy.support.strings import snake_case
23
+
24
+
25
+ def _paint(text: str, code: str) -> str:
26
+ """Format text with ANSI escape codes only if stdout is a terminal and NO_COLOR is unset."""
27
+ if sys.stdout.isatty() and "NO_COLOR" not in os.environ:
28
+ return f"\033[{code}m{text}\033[0m"
29
+ return text
30
+
31
+
32
+ def _run_coroutine(coro: Any) -> Any:
33
+ """Run an async coroutine synchronously, safely handling existing active event loops."""
34
+ try:
35
+ asyncio.get_running_loop()
36
+ except RuntimeError:
37
+ return asyncio.run(coro)
38
+
39
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
40
+ return executor.submit(asyncio.run, coro).result()
41
+
42
+
43
+ class SeedCommand:
44
+ """Console command for seeding database records."""
45
+
46
+ @staticmethod
47
+ def register(
48
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
49
+ ) -> None:
50
+ parser = subparsers.add_parser(
51
+ "db:seed",
52
+ help="Seed the database with records",
53
+ )
54
+ parser.add_argument(
55
+ "--class",
56
+ dest="class_name",
57
+ default="DatabaseSeeder",
58
+ help="The class name of the root seeder (default: DatabaseSeeder)",
59
+ )
60
+ parser.add_argument(
61
+ "--no-transaction",
62
+ dest="no_transaction",
63
+ action="store_true",
64
+ default=False,
65
+ help="Run seeders without wrapping in a transaction",
66
+ )
67
+ parser.add_argument(
68
+ "--app",
69
+ default="main:app",
70
+ help="Application target (module:attribute)",
71
+ )
72
+ parser.add_argument(
73
+ "--path",
74
+ default=None,
75
+ help="Path to seeders directory (defaults to database/seeders)",
76
+ )
77
+ parser.add_argument(
78
+ "--force",
79
+ action="store_true",
80
+ default=False,
81
+ help="Force seed execution when running in production",
82
+ )
83
+ parser.set_defaults(
84
+ _handler=SeedCommand.handle,
85
+ )
86
+
87
+ @staticmethod
88
+ def _enforce_production_guard(
89
+ args: argparse.Namespace, app: Any | None = None
90
+ ) -> None:
91
+ if getattr(args, "force", False):
92
+ return
93
+ cfg_env = ""
94
+ if app is not None and hasattr(app, "config") and hasattr(app.config, "get"):
95
+ raw_cfg_env = app.config.get("app.env") or app.config.get("env")
96
+ if isinstance(raw_cfg_env, str):
97
+ cfg_env = raw_cfg_env
98
+ env_val = env("SWIFTPY_APP__ENV") or env("APP_ENV") or cfg_env
99
+ if str(env_val).strip().lower() in ("production", "prod"):
100
+ print(
101
+ "Error: Application is in production. Pass --force to run seeders in production.",
102
+ file=sys.stderr,
103
+ )
104
+ raise SystemExit(1)
105
+
106
+ @staticmethod
107
+ def _validate_seeder_class(candidate: Any) -> type[Seeder] | None:
108
+ if (
109
+ isinstance(candidate, type)
110
+ and issubclass(candidate, Seeder)
111
+ and candidate is not Seeder
112
+ ):
113
+ if inspect.isabstract(candidate):
114
+ raise SystemExit(
115
+ f"Cannot run abstract seeder class '{candidate.__name__}'."
116
+ )
117
+ return candidate
118
+ return None
119
+
120
+ @staticmethod
121
+ def _ast_defines_class(path: Path, class_name: str) -> bool:
122
+ try:
123
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
124
+ except (OSError, SyntaxError):
125
+ return False
126
+
127
+ for node in tree.body:
128
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
129
+ return True
130
+ if isinstance(node, ast.ImportFrom):
131
+ for alias in node.names:
132
+ if (alias.asname or alias.name) == class_name:
133
+ return True
134
+ return False
135
+
136
+ @classmethod
137
+ def _find_seeder_class(
138
+ cls,
139
+ class_name: str,
140
+ seeders_dir: Path,
141
+ ) -> type[Seeder]:
142
+ """Dynamically load and resolve the Seeder class without executing unrelated files."""
143
+ if "." in class_name:
144
+ try:
145
+ mod_path, cls_name = class_name.rsplit(".", 1)
146
+ mod = importlib.import_module(mod_path)
147
+ validated = cls._validate_seeder_class(getattr(mod, cls_name, None))
148
+ if validated is not None:
149
+ return validated
150
+ except (ImportError, AttributeError):
151
+ pass
152
+ else:
153
+ if not class_name.isidentifier():
154
+ raise SystemExit(
155
+ f"Invalid seeder class name '{class_name}'."
156
+ )
157
+
158
+ snake_file = seeders_dir / f"{snake_case(class_name)}.py"
159
+ direct_file = seeders_dir / f"{class_name}.py"
160
+ candidate_files: list[Path] = []
161
+ if snake_file.exists():
162
+ candidate_files.append(snake_file)
163
+ if direct_file.exists() and direct_file not in candidate_files:
164
+ candidate_files.append(direct_file)
165
+ if seeders_dir.is_dir():
166
+ for f in sorted(seeders_dir.glob("*.py")):
167
+ if f not in candidate_files and not f.name.startswith("_"):
168
+ candidate_files.append(f)
169
+
170
+ for target_file in candidate_files:
171
+ if not cls._ast_defines_class(target_file, class_name):
172
+ continue
173
+ mod_name = f"_seeders.{target_file.stem}"
174
+ resolved_path = str(target_file.resolve())
175
+ existing_mod = sys.modules.get(mod_name)
176
+ loaded_mod: ModuleType | None = None
177
+ if (
178
+ existing_mod is not None
179
+ and getattr(existing_mod, "__file__", None) == resolved_path
180
+ ):
181
+ loaded_mod = existing_mod
182
+ else:
183
+ spec = importlib.util.spec_from_file_location(mod_name, resolved_path)
184
+ if spec and spec.loader:
185
+ loaded_mod = importlib.util.module_from_spec(spec)
186
+ sys.modules[mod_name] = loaded_mod
187
+ try:
188
+ spec.loader.exec_module(loaded_mod)
189
+ except BaseException:
190
+ sys.modules.pop(mod_name, None)
191
+ raise
192
+
193
+ if loaded_mod is not None:
194
+ validated = cls._validate_seeder_class(
195
+ getattr(loaded_mod, class_name, None)
196
+ )
197
+ if validated is not None:
198
+ return validated
199
+
200
+ try:
201
+ fallback_mod = importlib.import_module(
202
+ f"database.seeders.{snake_case(class_name)}"
203
+ )
204
+ validated = cls._validate_seeder_class(
205
+ getattr(fallback_mod, class_name, None)
206
+ )
207
+ if validated is not None:
208
+ return validated
209
+ except (ImportError, AttributeError):
210
+ pass
211
+
212
+ raise SystemExit(
213
+ f"Seeder class '{class_name}' could not be found in {seeders_dir} or python path."
214
+ )
215
+
216
+ @classmethod
217
+ async def _execute_seed(cls, args: argparse.Namespace) -> None:
218
+ app = load_application(args.app)
219
+ cls._enforce_production_guard(args, app)
220
+ pool = app.container.resolve(DatabasePool)
221
+ pool_initialized = False
222
+
223
+ try:
224
+ await pool.init()
225
+ pool_initialized = True
226
+ seeders_dir = (
227
+ Path(args.path) if args.path else Path.cwd() / "database" / "seeders"
228
+ )
229
+ seeder_cls = cls._find_seeder_class(args.class_name, seeders_dir)
230
+ if inspect.isabstract(seeder_cls):
231
+ raise SystemExit(
232
+ f"Cannot run abstract seeder class '{seeder_cls.__name__}'."
233
+ )
234
+ seeder = seeder_cls()
235
+
236
+ start_time = time.perf_counter()
237
+ if getattr(args, "no_transaction", False):
238
+ await seeder.run()
239
+ else:
240
+ async with db.transaction():
241
+ await seeder.run()
242
+ elapsed = time.perf_counter() - start_time
243
+
244
+ print(f"{_paint('Seeded:', '32')} {seeder_cls.__name__} ({elapsed:.2f}s)")
245
+ finally:
246
+ if pool_initialized:
247
+ await pool.close()
248
+
249
+ @classmethod
250
+ def handle(cls, args: argparse.Namespace) -> None:
251
+ """Handle db:seed command dispatch."""
252
+ _run_coroutine(cls._execute_seed(args))