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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|