jararaca 0.2.37a12__py3-none-any.whl → 0.4.0a5__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.
- README.md +121 -0
- jararaca/__init__.py +267 -15
- jararaca/__main__.py +4 -0
- jararaca/broker_backend/__init__.py +106 -0
- jararaca/broker_backend/mapper.py +25 -0
- jararaca/broker_backend/redis_broker_backend.py +168 -0
- jararaca/cli.py +840 -103
- jararaca/common/__init__.py +3 -0
- jararaca/core/__init__.py +3 -0
- jararaca/core/providers.py +4 -0
- jararaca/core/uow.py +55 -16
- jararaca/di.py +4 -0
- jararaca/files/entity.py.mako +4 -0
- jararaca/lifecycle.py +6 -2
- jararaca/messagebus/__init__.py +5 -1
- jararaca/messagebus/bus_message_controller.py +4 -0
- jararaca/messagebus/consumers/__init__.py +3 -0
- jararaca/messagebus/decorators.py +90 -85
- jararaca/messagebus/implicit_headers.py +49 -0
- jararaca/messagebus/interceptors/__init__.py +3 -0
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
- jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
- jararaca/messagebus/message.py +31 -0
- jararaca/messagebus/publisher.py +47 -4
- jararaca/messagebus/worker.py +1615 -135
- jararaca/microservice.py +248 -36
- jararaca/observability/constants.py +7 -0
- jararaca/observability/decorators.py +177 -16
- jararaca/observability/fastapi_exception_handler.py +37 -0
- jararaca/observability/hooks.py +109 -0
- jararaca/observability/interceptor.py +8 -2
- jararaca/observability/providers/__init__.py +3 -0
- jararaca/observability/providers/otel.py +213 -18
- jararaca/persistence/base.py +40 -3
- jararaca/persistence/exports.py +4 -0
- jararaca/persistence/interceptors/__init__.py +3 -0
- jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
- jararaca/persistence/interceptors/constants.py +5 -0
- jararaca/persistence/interceptors/decorators.py +50 -0
- jararaca/persistence/session.py +3 -0
- jararaca/persistence/sort_filter.py +4 -0
- jararaca/persistence/utilities.py +74 -32
- jararaca/presentation/__init__.py +3 -0
- jararaca/presentation/decorators.py +170 -82
- jararaca/presentation/exceptions.py +23 -0
- jararaca/presentation/hooks.py +4 -0
- jararaca/presentation/http_microservice.py +4 -0
- jararaca/presentation/server.py +120 -41
- jararaca/presentation/websocket/__init__.py +3 -0
- jararaca/presentation/websocket/base_types.py +4 -0
- jararaca/presentation/websocket/context.py +34 -4
- jararaca/presentation/websocket/decorators.py +8 -41
- jararaca/presentation/websocket/redis.py +280 -53
- jararaca/presentation/websocket/types.py +6 -2
- jararaca/presentation/websocket/websocket_interceptor.py +74 -23
- jararaca/reflect/__init__.py +3 -0
- jararaca/reflect/controller_inspect.py +81 -0
- jararaca/reflect/decorators.py +238 -0
- jararaca/reflect/metadata.py +76 -0
- jararaca/rpc/__init__.py +3 -0
- jararaca/rpc/http/__init__.py +101 -0
- jararaca/rpc/http/backends/__init__.py +14 -0
- jararaca/rpc/http/backends/httpx.py +43 -9
- jararaca/rpc/http/backends/otel.py +4 -0
- jararaca/rpc/http/decorators.py +378 -113
- jararaca/rpc/http/httpx.py +3 -0
- jararaca/scheduler/__init__.py +3 -0
- jararaca/scheduler/beat_worker.py +758 -0
- jararaca/scheduler/decorators.py +89 -28
- jararaca/scheduler/types.py +11 -0
- jararaca/tools/app_config/__init__.py +3 -0
- jararaca/tools/app_config/decorators.py +7 -19
- jararaca/tools/app_config/interceptor.py +10 -4
- jararaca/tools/typescript/__init__.py +3 -0
- jararaca/tools/typescript/decorators.py +120 -0
- jararaca/tools/typescript/interface_parser.py +1126 -189
- jararaca/utils/__init__.py +3 -0
- jararaca/utils/rabbitmq_utils.py +372 -0
- jararaca/utils/retry.py +148 -0
- jararaca-0.4.0a5.dist-info/LICENSE +674 -0
- jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
- jararaca-0.4.0a5.dist-info/RECORD +88 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
- pyproject.toml +131 -0
- jararaca/messagebus/types.py +0 -30
- jararaca/scheduler/scheduler.py +0 -154
- jararaca/tools/metadata.py +0 -47
- jararaca-0.2.37a12.dist-info/RECORD +0 -63
- /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
jararaca/cli.py
CHANGED
|
@@ -1,24 +1,44 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Lucas S
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
1
6
|
import importlib
|
|
2
7
|
import importlib.resources
|
|
8
|
+
import multiprocessing
|
|
3
9
|
import os
|
|
4
10
|
import sys
|
|
5
11
|
import time
|
|
12
|
+
import traceback
|
|
13
|
+
import typing
|
|
6
14
|
from codecs import StreamWriter
|
|
7
|
-
from
|
|
8
|
-
from
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
17
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
18
|
|
|
19
|
+
import aio_pika
|
|
10
20
|
import click
|
|
11
21
|
import uvicorn
|
|
12
|
-
from mako.template import Template
|
|
22
|
+
from mako.template import Template
|
|
13
23
|
|
|
14
|
-
from jararaca.messagebus
|
|
24
|
+
from jararaca.messagebus import worker as worker_mod
|
|
25
|
+
from jararaca.messagebus.decorators import MessageBusController, MessageHandler
|
|
15
26
|
from jararaca.microservice import Microservice
|
|
16
27
|
from jararaca.presentation.http_microservice import HttpMicroservice
|
|
17
28
|
from jararaca.presentation.server import create_http_server
|
|
18
|
-
from jararaca.
|
|
29
|
+
from jararaca.reflect.controller_inspect import (
|
|
30
|
+
ControllerMemberReflect,
|
|
31
|
+
inspect_controller,
|
|
32
|
+
)
|
|
33
|
+
from jararaca.scheduler.beat_worker import BeatWorker
|
|
34
|
+
from jararaca.scheduler.decorators import ScheduledAction
|
|
19
35
|
from jararaca.tools.typescript.interface_parser import (
|
|
20
36
|
write_microservice_to_typescript_interface,
|
|
21
37
|
)
|
|
38
|
+
from jararaca.utils.rabbitmq_utils import RabbitmqUtils
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from watchdog.observers.api import BaseObserver
|
|
22
42
|
|
|
23
43
|
LIBRARY_FILES_PATH = importlib.resources.files("jararaca.files")
|
|
24
44
|
ENTITY_TEMPLATE_PATH = LIBRARY_FILES_PATH / "entity.py.mako"
|
|
@@ -35,7 +55,10 @@ def find_item_by_module_path(
|
|
|
35
55
|
try:
|
|
36
56
|
module = importlib.import_module(module_name)
|
|
37
57
|
except ImportError as e:
|
|
38
|
-
|
|
58
|
+
if e.name == module_name:
|
|
59
|
+
raise ImportError("Module not found") from e
|
|
60
|
+
else:
|
|
61
|
+
raise
|
|
39
62
|
|
|
40
63
|
if not hasattr(module, app):
|
|
41
64
|
raise ValueError("module %s has no attribute %s" % (module, app))
|
|
@@ -60,6 +83,326 @@ def find_microservice_by_module_path(module_path: str) -> Microservice:
|
|
|
60
83
|
return app
|
|
61
84
|
|
|
62
85
|
|
|
86
|
+
# The v1 infrastructure declaration function has been removed as part of the CLI simplification
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def declare_worker_infrastructure(
|
|
90
|
+
broker_url: str,
|
|
91
|
+
app: Microservice,
|
|
92
|
+
force: bool = False,
|
|
93
|
+
interactive_mode: bool = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Declare the infrastructure (exchanges and queues) for worker.
|
|
97
|
+
"""
|
|
98
|
+
parsed_url = urlparse(broker_url)
|
|
99
|
+
if parsed_url.scheme not in ["amqp", "amqps"]:
|
|
100
|
+
raise ValueError(f"Unsupported broker URL scheme: {parsed_url.scheme}")
|
|
101
|
+
|
|
102
|
+
if not parsed_url.query:
|
|
103
|
+
raise ValueError("Query string must be set for AMQP URLs")
|
|
104
|
+
|
|
105
|
+
query_params = parse_qs(parsed_url.query)
|
|
106
|
+
|
|
107
|
+
if "exchange" not in query_params or not query_params["exchange"]:
|
|
108
|
+
raise ValueError("Exchange must be set in the query string")
|
|
109
|
+
|
|
110
|
+
exchange = query_params["exchange"][0]
|
|
111
|
+
|
|
112
|
+
# Create a connection that will be used to create channels for each operation
|
|
113
|
+
connection = await aio_pika.connect(broker_url)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Step 1: Setup infrastructure (exchanges and dead letter queues)
|
|
117
|
+
# Creating a dedicated channel for infrastructure setup
|
|
118
|
+
await setup_infrastructure(connection, exchange, force, interactive_mode)
|
|
119
|
+
|
|
120
|
+
# Step 2: Declare all message handlers and scheduled actions queues
|
|
121
|
+
# Creating a dedicated channel for controller queues
|
|
122
|
+
await declare_controller_queues(
|
|
123
|
+
connection, app, exchange, force, interactive_mode
|
|
124
|
+
)
|
|
125
|
+
finally:
|
|
126
|
+
# Always close the connection in the finally block
|
|
127
|
+
await connection.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def setup_infrastructure(
|
|
131
|
+
connection: aio_pika.abc.AbstractConnection,
|
|
132
|
+
exchange: str,
|
|
133
|
+
force: bool = False,
|
|
134
|
+
interactive_mode: bool = False,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Setup the basic infrastructure (exchanges and dead letter queues).
|
|
138
|
+
"""
|
|
139
|
+
# Check if infrastructure exists
|
|
140
|
+
infrastructure_exists = await check_infrastructure_exists(connection, exchange)
|
|
141
|
+
|
|
142
|
+
# If it exists and force or user confirms, delete it
|
|
143
|
+
if not infrastructure_exists and should_recreate_infrastructure(
|
|
144
|
+
force,
|
|
145
|
+
interactive_mode,
|
|
146
|
+
f"Existing infrastructure found for exchange '{exchange}'. Delete and recreate?",
|
|
147
|
+
):
|
|
148
|
+
await delete_infrastructure(connection, exchange)
|
|
149
|
+
|
|
150
|
+
# Try to declare required infrastructure
|
|
151
|
+
await declare_infrastructure(connection, exchange, force, interactive_mode)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def check_infrastructure_exists(
|
|
155
|
+
connection: aio_pika.abc.AbstractConnection, exchange: str
|
|
156
|
+
) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Check if the infrastructure exists by trying passive declarations.
|
|
159
|
+
"""
|
|
160
|
+
# Create a dedicated channel for checking infrastructure existence using async context manager
|
|
161
|
+
async with connection.channel() as channel:
|
|
162
|
+
try:
|
|
163
|
+
await RabbitmqUtils.declare_main_exchange(
|
|
164
|
+
channel=channel,
|
|
165
|
+
exchange_name=exchange,
|
|
166
|
+
passive=True,
|
|
167
|
+
)
|
|
168
|
+
await RabbitmqUtils.declare_dl_exchange(channel=channel, passive=True)
|
|
169
|
+
await RabbitmqUtils.declare_dl_queue(channel=channel, passive=True)
|
|
170
|
+
return True
|
|
171
|
+
except Exception:
|
|
172
|
+
# Infrastructure doesn't exist, which is fine for fresh setup
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def should_recreate_infrastructure(
|
|
177
|
+
force: bool, interactive_mode: bool, confirmation_message: str
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Determine if infrastructure should be recreated based on force flag and user input.
|
|
181
|
+
"""
|
|
182
|
+
return force or (interactive_mode and click.confirm(confirmation_message))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def delete_infrastructure(
|
|
186
|
+
connection: aio_pika.abc.AbstractConnection, exchange: str
|
|
187
|
+
) -> None:
|
|
188
|
+
"""
|
|
189
|
+
Delete existing infrastructure (exchanges and queues).
|
|
190
|
+
"""
|
|
191
|
+
# Create a dedicated channel for deleting infrastructure using async context manager
|
|
192
|
+
async with connection.channel() as channel:
|
|
193
|
+
click.echo(f"→ Deleting existing infrastructure for exchange: {exchange}")
|
|
194
|
+
await RabbitmqUtils.delete_exchange(channel, exchange)
|
|
195
|
+
await RabbitmqUtils.delete_exchange(channel, RabbitmqUtils.DEAD_LETTER_EXCHANGE)
|
|
196
|
+
await RabbitmqUtils.delete_queue(channel, RabbitmqUtils.DEAD_LETTER_QUEUE)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def declare_infrastructure(
|
|
200
|
+
connection: aio_pika.abc.AbstractConnection,
|
|
201
|
+
exchange: str,
|
|
202
|
+
force: bool,
|
|
203
|
+
interactive_mode: bool,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Declare the required infrastructure (exchanges and dead letter queues).
|
|
207
|
+
"""
|
|
208
|
+
# Using async context manager for channel creation
|
|
209
|
+
async with connection.channel() as channel:
|
|
210
|
+
try:
|
|
211
|
+
# Declare main exchange
|
|
212
|
+
await RabbitmqUtils.declare_main_exchange(
|
|
213
|
+
channel=channel,
|
|
214
|
+
exchange_name=exchange,
|
|
215
|
+
passive=False,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Declare dead letter exchange and queue
|
|
219
|
+
dlx = await RabbitmqUtils.declare_dl_exchange(
|
|
220
|
+
channel=channel, passive=False
|
|
221
|
+
)
|
|
222
|
+
dlq = await RabbitmqUtils.declare_dl_queue(channel=channel, passive=False)
|
|
223
|
+
await dlq.bind(dlx, routing_key=RabbitmqUtils.DEAD_LETTER_EXCHANGE)
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
click.echo(f"Error during exchange declaration: {e}")
|
|
227
|
+
|
|
228
|
+
# If interactive mode and user confirms, or if forced, try again after deletion
|
|
229
|
+
if should_recreate_infrastructure(
|
|
230
|
+
force, interactive_mode, "Delete existing infrastructure and recreate?"
|
|
231
|
+
):
|
|
232
|
+
# Delete infrastructure with a new channel
|
|
233
|
+
await delete_infrastructure(connection, exchange)
|
|
234
|
+
|
|
235
|
+
# Try again with a new channel
|
|
236
|
+
async with connection.channel() as new_channel:
|
|
237
|
+
# Try again after deletion
|
|
238
|
+
await RabbitmqUtils.declare_main_exchange(
|
|
239
|
+
channel=new_channel,
|
|
240
|
+
exchange_name=exchange,
|
|
241
|
+
passive=False,
|
|
242
|
+
)
|
|
243
|
+
dlx = await RabbitmqUtils.declare_dl_exchange(
|
|
244
|
+
channel=new_channel, passive=False
|
|
245
|
+
)
|
|
246
|
+
dlq = await RabbitmqUtils.declare_dl_queue(
|
|
247
|
+
channel=new_channel, passive=False
|
|
248
|
+
)
|
|
249
|
+
await dlq.bind(dlx, routing_key=RabbitmqUtils.DEAD_LETTER_EXCHANGE)
|
|
250
|
+
elif force:
|
|
251
|
+
# If force is true but recreation failed, propagate the error
|
|
252
|
+
raise
|
|
253
|
+
else:
|
|
254
|
+
click.echo("Skipping main exchange declaration due to error")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def declare_controller_queues(
|
|
258
|
+
connection: aio_pika.abc.AbstractConnection,
|
|
259
|
+
app: Microservice,
|
|
260
|
+
exchange: str,
|
|
261
|
+
force: bool,
|
|
262
|
+
interactive_mode: bool,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Declare all message handler and scheduled action queues for controllers.
|
|
266
|
+
"""
|
|
267
|
+
for instance_type in app.controllers:
|
|
268
|
+
controller_spec = MessageBusController.get_last(instance_type)
|
|
269
|
+
if controller_spec is None:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
_, members = inspect_controller(instance_type)
|
|
273
|
+
|
|
274
|
+
# Process each member (method) in the controller
|
|
275
|
+
for _, member in members.items():
|
|
276
|
+
# Check if it's a message handler
|
|
277
|
+
await declare_message_handler_queue(
|
|
278
|
+
connection, member, exchange, force, interactive_mode, controller_spec
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Check if it's a scheduled action
|
|
282
|
+
await declare_scheduled_action_queue(
|
|
283
|
+
connection, member, exchange, force, interactive_mode
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def declare_message_handler_queue(
|
|
288
|
+
connection: aio_pika.abc.AbstractConnection,
|
|
289
|
+
member: ControllerMemberReflect,
|
|
290
|
+
exchange: str,
|
|
291
|
+
force: bool,
|
|
292
|
+
interactive_mode: bool,
|
|
293
|
+
controller_spec: MessageBusController,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Declare a queue for a message handler if the member is one.
|
|
297
|
+
"""
|
|
298
|
+
message_handler = MessageHandler.get_last(member.member_function)
|
|
299
|
+
if message_handler is not None:
|
|
300
|
+
queue_name = f"{message_handler.message_type.MESSAGE_TOPIC}.{member.member_function.__module__}.{member.member_function.__qualname__}"
|
|
301
|
+
routing_key = f"{message_handler.message_type.MESSAGE_TOPIC}.#"
|
|
302
|
+
|
|
303
|
+
await declare_and_bind_queue(
|
|
304
|
+
connection,
|
|
305
|
+
queue_name,
|
|
306
|
+
routing_key,
|
|
307
|
+
exchange,
|
|
308
|
+
force,
|
|
309
|
+
interactive_mode,
|
|
310
|
+
is_scheduled_action=False,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def declare_scheduled_action_queue(
|
|
315
|
+
connection: aio_pika.abc.AbstractConnection,
|
|
316
|
+
member: ControllerMemberReflect,
|
|
317
|
+
exchange: str,
|
|
318
|
+
force: bool,
|
|
319
|
+
interactive_mode: bool,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""
|
|
322
|
+
Declare a queue for a scheduled action if the member is one.
|
|
323
|
+
"""
|
|
324
|
+
scheduled_action = ScheduledAction.get_last(member.member_function)
|
|
325
|
+
if scheduled_action is not None:
|
|
326
|
+
queue_name = (
|
|
327
|
+
f"{member.member_function.__module__}.{member.member_function.__qualname__}"
|
|
328
|
+
)
|
|
329
|
+
routing_key = queue_name
|
|
330
|
+
|
|
331
|
+
await declare_and_bind_queue(
|
|
332
|
+
connection,
|
|
333
|
+
queue_name,
|
|
334
|
+
routing_key,
|
|
335
|
+
exchange,
|
|
336
|
+
force,
|
|
337
|
+
interactive_mode,
|
|
338
|
+
is_scheduled_action=True,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def declare_and_bind_queue(
|
|
343
|
+
connection: aio_pika.abc.AbstractConnection,
|
|
344
|
+
queue_name: str,
|
|
345
|
+
routing_key: str,
|
|
346
|
+
exchange: str,
|
|
347
|
+
force: bool,
|
|
348
|
+
interactive_mode: bool,
|
|
349
|
+
is_scheduled_action: bool,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Declare and bind a queue to the exchange with the given routing key.
|
|
353
|
+
"""
|
|
354
|
+
queue_type = "scheduled action" if is_scheduled_action else "message handler"
|
|
355
|
+
|
|
356
|
+
# Using async context manager for channel creation
|
|
357
|
+
async with connection.channel() as channel:
|
|
358
|
+
try:
|
|
359
|
+
# Try to declare queue using the appropriate method
|
|
360
|
+
if is_scheduled_action:
|
|
361
|
+
queue = await RabbitmqUtils.declare_scheduled_action_queue(
|
|
362
|
+
channel=channel, queue_name=queue_name, passive=False
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
queue = await RabbitmqUtils.declare_worker_queue(
|
|
366
|
+
channel=channel, queue_name=queue_name, passive=False
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Bind the queue to the exchange
|
|
370
|
+
await queue.bind(exchange=exchange, routing_key=routing_key)
|
|
371
|
+
click.echo(
|
|
372
|
+
f"✓ Declared {queue_type} queue: {queue_name} (routing key: {routing_key})"
|
|
373
|
+
)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
click.echo(f"⚠ Error declaring {queue_type} queue {queue_name}: {e}")
|
|
376
|
+
|
|
377
|
+
# If interactive mode and user confirms, or if forced, recreate the queue
|
|
378
|
+
if force or (
|
|
379
|
+
interactive_mode
|
|
380
|
+
and click.confirm(f"Delete and recreate queue {queue_name}?")
|
|
381
|
+
):
|
|
382
|
+
# Create a new channel for deletion and recreation
|
|
383
|
+
async with connection.channel() as new_channel:
|
|
384
|
+
# Delete the queue
|
|
385
|
+
await RabbitmqUtils.delete_queue(new_channel, queue_name)
|
|
386
|
+
|
|
387
|
+
# Try to declare queue again using the appropriate method
|
|
388
|
+
if is_scheduled_action:
|
|
389
|
+
queue = await RabbitmqUtils.declare_scheduled_action_queue(
|
|
390
|
+
channel=new_channel, queue_name=queue_name, passive=False
|
|
391
|
+
)
|
|
392
|
+
else:
|
|
393
|
+
queue = await RabbitmqUtils.declare_worker_queue(
|
|
394
|
+
channel=new_channel, queue_name=queue_name, passive=False
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Bind the queue to the exchange
|
|
398
|
+
await queue.bind(exchange=exchange, routing_key=routing_key)
|
|
399
|
+
click.echo(
|
|
400
|
+
f"✓ Recreated {queue_type} queue: {queue_name} (routing key: {routing_key})"
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
click.echo(f"⚠ Skipping {queue_type} queue {queue_name}")
|
|
404
|
+
|
|
405
|
+
|
|
63
406
|
@click.group()
|
|
64
407
|
def cli() -> None:
|
|
65
408
|
pass
|
|
@@ -69,95 +412,90 @@ def cli() -> None:
|
|
|
69
412
|
@click.argument(
|
|
70
413
|
"app_path",
|
|
71
414
|
type=str,
|
|
415
|
+
envvar="APP_PATH",
|
|
72
416
|
)
|
|
73
417
|
@click.option(
|
|
74
|
-
"--url",
|
|
418
|
+
"--broker-url",
|
|
75
419
|
type=str,
|
|
76
|
-
|
|
420
|
+
envvar="BROKER_URL",
|
|
421
|
+
required=True,
|
|
422
|
+
help="The URL for the message broker",
|
|
77
423
|
)
|
|
78
424
|
@click.option(
|
|
79
|
-
"--
|
|
425
|
+
"--backend-url",
|
|
80
426
|
type=str,
|
|
81
|
-
|
|
427
|
+
envvar="BACKEND_URL",
|
|
428
|
+
required=True,
|
|
429
|
+
help="The URL for the message broker backend",
|
|
82
430
|
)
|
|
83
431
|
@click.option(
|
|
84
|
-
"--
|
|
432
|
+
"--handlers",
|
|
85
433
|
type=str,
|
|
86
|
-
|
|
434
|
+
envvar="HANDLERS",
|
|
435
|
+
help="Comma-separated list of handler names to listen to. If not specified, all handlers will be used.",
|
|
87
436
|
)
|
|
88
437
|
@click.option(
|
|
89
|
-
"--
|
|
90
|
-
|
|
91
|
-
|
|
438
|
+
"--reload",
|
|
439
|
+
is_flag=True,
|
|
440
|
+
envvar="RELOAD",
|
|
441
|
+
help="Enable auto-reload when Python files change.",
|
|
92
442
|
)
|
|
93
443
|
@click.option(
|
|
94
|
-
"--
|
|
95
|
-
type=
|
|
96
|
-
default="
|
|
444
|
+
"--src-dir",
|
|
445
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
|
446
|
+
default="src",
|
|
447
|
+
envvar="SRC_DIR",
|
|
448
|
+
help="The source directory to watch for changes when --reload is enabled.",
|
|
97
449
|
)
|
|
98
450
|
@click.option(
|
|
99
|
-
"--
|
|
451
|
+
"--gracious-shutdown-seconds",
|
|
100
452
|
type=int,
|
|
101
|
-
default=
|
|
453
|
+
default=20,
|
|
454
|
+
envvar="GRACIOUS_SHUTDOWN_SECONDS",
|
|
455
|
+
help="Number of seconds to wait for graceful shutdown on reload",
|
|
102
456
|
)
|
|
103
457
|
def worker(
|
|
104
458
|
app_path: str,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
459
|
+
broker_url: str,
|
|
460
|
+
backend_url: str,
|
|
461
|
+
handlers: str | None,
|
|
462
|
+
reload: bool,
|
|
463
|
+
src_dir: str,
|
|
464
|
+
gracious_shutdown_seconds: int,
|
|
111
465
|
) -> None:
|
|
466
|
+
"""Start a message bus worker that processes asynchronous messages from a message queue."""
|
|
112
467
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
)
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
if username is not None:
|
|
127
|
-
parsed_url = urlparse(
|
|
128
|
-
urlunsplit(
|
|
129
|
-
parsed_url._replace(
|
|
130
|
-
netloc=f"{username}{':%s' % password if password is not None else ''}@{parsed_url.netloc}"
|
|
131
|
-
)
|
|
132
|
-
)
|
|
468
|
+
if reload:
|
|
469
|
+
process_args = {
|
|
470
|
+
"app_path": app_path,
|
|
471
|
+
"broker_url": broker_url,
|
|
472
|
+
"backend_url": backend_url,
|
|
473
|
+
"handlers": handlers,
|
|
474
|
+
}
|
|
475
|
+
run_with_reload_watcher(
|
|
476
|
+
process_args, run_worker_process, src_dir, gracious_shutdown_seconds
|
|
133
477
|
)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
config = AioPikaWorkerConfig(
|
|
138
|
-
url=url,
|
|
139
|
-
exchange=exchange,
|
|
140
|
-
queue=queue,
|
|
141
|
-
prefetch_count=prefetch_count,
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
MessageBusWorker(app, config=config).start_sync()
|
|
478
|
+
else:
|
|
479
|
+
run_worker_process(app_path, broker_url, backend_url, handlers)
|
|
145
480
|
|
|
146
481
|
|
|
147
482
|
@cli.command()
|
|
148
483
|
@click.argument(
|
|
149
484
|
"app_path",
|
|
150
485
|
type=str,
|
|
486
|
+
envvar="APP_PATH",
|
|
151
487
|
)
|
|
152
488
|
@click.option(
|
|
153
489
|
"--host",
|
|
154
490
|
type=str,
|
|
155
491
|
default="0.0.0.0",
|
|
492
|
+
envvar="HOST",
|
|
156
493
|
)
|
|
157
494
|
@click.option(
|
|
158
495
|
"--port",
|
|
159
496
|
type=int,
|
|
160
497
|
default=8000,
|
|
498
|
+
envvar="PORT",
|
|
161
499
|
)
|
|
162
500
|
def server(app_path: str, host: str, port: int) -> None:
|
|
163
501
|
|
|
@@ -180,74 +518,208 @@ def server(app_path: str, host: str, port: int) -> None:
|
|
|
180
518
|
uvicorn.run(asgi_app, host=host, port=port)
|
|
181
519
|
|
|
182
520
|
|
|
183
|
-
class NullBackend(SchedulerBackend): ...
|
|
184
|
-
|
|
185
|
-
|
|
186
521
|
@cli.command()
|
|
187
522
|
@click.argument(
|
|
188
523
|
"app_path",
|
|
189
524
|
type=str,
|
|
525
|
+
envvar="APP_PATH",
|
|
190
526
|
)
|
|
191
527
|
@click.option(
|
|
192
528
|
"--interval",
|
|
193
529
|
type=int,
|
|
194
530
|
default=1,
|
|
531
|
+
required=True,
|
|
532
|
+
envvar="INTERVAL",
|
|
195
533
|
)
|
|
196
|
-
|
|
197
|
-
|
|
534
|
+
@click.option(
|
|
535
|
+
"--broker-url",
|
|
536
|
+
type=str,
|
|
537
|
+
required=True,
|
|
538
|
+
envvar="BROKER_URL",
|
|
539
|
+
)
|
|
540
|
+
@click.option(
|
|
541
|
+
"--backend-url",
|
|
542
|
+
type=str,
|
|
543
|
+
required=True,
|
|
544
|
+
envvar="BACKEND_URL",
|
|
545
|
+
)
|
|
546
|
+
@click.option(
|
|
547
|
+
"--actions",
|
|
548
|
+
type=str,
|
|
549
|
+
envvar="ACTIONS",
|
|
550
|
+
help="Comma-separated list of action names to run (only run actions with these names)",
|
|
551
|
+
)
|
|
552
|
+
@click.option(
|
|
553
|
+
"--reload",
|
|
554
|
+
is_flag=True,
|
|
555
|
+
envvar="RELOAD",
|
|
556
|
+
help="Enable auto-reload when Python files change.",
|
|
557
|
+
)
|
|
558
|
+
@click.option(
|
|
559
|
+
"--src-dir",
|
|
560
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
|
561
|
+
default="src",
|
|
562
|
+
envvar="SRC_DIR",
|
|
563
|
+
help="The source directory to watch for changes when --reload is enabled.",
|
|
564
|
+
)
|
|
565
|
+
@click.option(
|
|
566
|
+
"--gracious-shutdown-seconds",
|
|
567
|
+
type=int,
|
|
568
|
+
default=20,
|
|
569
|
+
envvar="GRACIOUS_SHUTDOWN_SECONDS",
|
|
570
|
+
help="Number of seconds to wait for graceful shutdown on reload",
|
|
571
|
+
)
|
|
572
|
+
def beat(
|
|
198
573
|
interval: int,
|
|
574
|
+
broker_url: str,
|
|
575
|
+
backend_url: str,
|
|
576
|
+
app_path: str,
|
|
577
|
+
actions: str | None = None,
|
|
578
|
+
reload: bool = False,
|
|
579
|
+
src_dir: str = "src",
|
|
580
|
+
gracious_shutdown_seconds: int = 20,
|
|
199
581
|
) -> None:
|
|
200
|
-
|
|
582
|
+
"""Start a scheduler that dispatches scheduled actions to workers."""
|
|
583
|
+
|
|
584
|
+
if reload:
|
|
585
|
+
process_args = {
|
|
586
|
+
"app_path": app_path,
|
|
587
|
+
"interval": interval,
|
|
588
|
+
"broker_url": broker_url,
|
|
589
|
+
"backend_url": backend_url,
|
|
590
|
+
"actions": actions,
|
|
591
|
+
}
|
|
592
|
+
run_with_reload_watcher(
|
|
593
|
+
process_args, run_beat_process, src_dir, gracious_shutdown_seconds
|
|
594
|
+
)
|
|
595
|
+
else:
|
|
596
|
+
run_beat_process(app_path, interval, broker_url, backend_url, actions)
|
|
201
597
|
|
|
202
|
-
|
|
598
|
+
|
|
599
|
+
def generate_interfaces(
|
|
600
|
+
app_path: str,
|
|
601
|
+
file_path: str | None = None,
|
|
602
|
+
stdout: bool = False,
|
|
603
|
+
post_process_cmd: str | None = None,
|
|
604
|
+
) -> str:
|
|
605
|
+
try:
|
|
606
|
+
app = find_microservice_by_module_path(app_path)
|
|
607
|
+
content = write_microservice_to_typescript_interface(app)
|
|
608
|
+
|
|
609
|
+
if stdout:
|
|
610
|
+
return content
|
|
611
|
+
|
|
612
|
+
if not file_path:
|
|
613
|
+
return content
|
|
614
|
+
|
|
615
|
+
with open(file_path, "w", encoding="utf-8") as file:
|
|
616
|
+
# Save current position
|
|
617
|
+
file.tell()
|
|
618
|
+
|
|
619
|
+
# Reset file to beginning
|
|
620
|
+
file.seek(0)
|
|
621
|
+
file.truncate()
|
|
622
|
+
|
|
623
|
+
# Write new content
|
|
624
|
+
file.write(content)
|
|
625
|
+
file.flush()
|
|
626
|
+
|
|
627
|
+
click.echo(
|
|
628
|
+
f"Generated TypeScript interfaces at {time.strftime('%H:%M:%S')} at {str(Path(file_path).absolute())}"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if post_process_cmd and file_path:
|
|
632
|
+
import subprocess
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
click.echo(f"Running post-process command: {post_process_cmd}")
|
|
636
|
+
subprocess.run(
|
|
637
|
+
post_process_cmd.replace("{file}", file_path),
|
|
638
|
+
shell=True,
|
|
639
|
+
check=True,
|
|
640
|
+
)
|
|
641
|
+
click.echo(f"Post-processing completed successfully")
|
|
642
|
+
except subprocess.CalledProcessError as e:
|
|
643
|
+
click.echo(f"Post-processing command failed: {e}", file=sys.stderr)
|
|
644
|
+
|
|
645
|
+
return content
|
|
646
|
+
except Exception as e:
|
|
647
|
+
click.echo(f"Error generating TypeScript interfaces: {e}", file=sys.stderr)
|
|
648
|
+
traceback.print_exc(file=sys.stderr)
|
|
649
|
+
return ""
|
|
203
650
|
|
|
204
651
|
|
|
205
652
|
@cli.command()
|
|
206
653
|
@click.argument(
|
|
207
654
|
"app_path",
|
|
208
655
|
type=str,
|
|
656
|
+
envvar="APP_PATH",
|
|
209
657
|
)
|
|
210
658
|
@click.argument(
|
|
211
659
|
"file_path",
|
|
212
|
-
type=click.
|
|
660
|
+
type=click.Path(file_okay=True, dir_okay=False),
|
|
661
|
+
required=False,
|
|
662
|
+
envvar="FILE_PATH",
|
|
213
663
|
)
|
|
214
664
|
@click.option(
|
|
215
665
|
"--watch",
|
|
216
666
|
is_flag=True,
|
|
217
|
-
|
|
667
|
+
envvar="WATCH",
|
|
218
668
|
)
|
|
219
669
|
@click.option(
|
|
220
670
|
"--src-dir",
|
|
221
671
|
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
|
222
672
|
default="src",
|
|
223
|
-
|
|
673
|
+
envvar="SRC_DIR",
|
|
224
674
|
)
|
|
225
|
-
|
|
675
|
+
@click.option(
|
|
676
|
+
"--stdout",
|
|
677
|
+
is_flag=True,
|
|
678
|
+
envvar="STDOUT",
|
|
679
|
+
help="Print generated interfaces to stdout instead of writing to a file",
|
|
680
|
+
)
|
|
681
|
+
@click.option(
|
|
682
|
+
"--post-process",
|
|
683
|
+
type=str,
|
|
684
|
+
envvar="POST_PROCESS",
|
|
685
|
+
help="Command to run after generating the interfaces, {file} will be replaced with the output file path",
|
|
686
|
+
)
|
|
687
|
+
def gen_tsi(
|
|
688
|
+
app_path: str,
|
|
689
|
+
file_path: str | None,
|
|
690
|
+
watch: bool,
|
|
691
|
+
src_dir: str,
|
|
692
|
+
stdout: bool,
|
|
693
|
+
post_process: str | None,
|
|
694
|
+
) -> None:
|
|
226
695
|
"""Generate TypeScript interfaces from a Python microservice."""
|
|
227
696
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
# Save current position
|
|
235
|
-
file_path.tell()
|
|
236
|
-
|
|
237
|
-
# Reset file to beginning
|
|
238
|
-
file_path.seek(0)
|
|
239
|
-
file_path.truncate()
|
|
697
|
+
if stdout and watch:
|
|
698
|
+
click.echo(
|
|
699
|
+
"Error: --watch and --stdout options cannot be used together",
|
|
700
|
+
file=sys.stderr,
|
|
701
|
+
)
|
|
702
|
+
return
|
|
240
703
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
file_path.
|
|
704
|
+
if not file_path and not stdout:
|
|
705
|
+
click.echo(
|
|
706
|
+
"Error: either file_path or --stdout must be provided", file=sys.stderr
|
|
707
|
+
)
|
|
708
|
+
return
|
|
244
709
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
710
|
+
if post_process and stdout:
|
|
711
|
+
click.echo(
|
|
712
|
+
"Error: --post-process and --stdout options cannot be used together",
|
|
713
|
+
file=sys.stderr,
|
|
714
|
+
)
|
|
715
|
+
return
|
|
248
716
|
|
|
249
717
|
# Initial generation
|
|
250
|
-
generate_interfaces()
|
|
718
|
+
content = generate_interfaces(app_path, file_path, stdout, post_process)
|
|
719
|
+
|
|
720
|
+
if stdout:
|
|
721
|
+
click.echo(content)
|
|
722
|
+
return
|
|
251
723
|
|
|
252
724
|
# If watch mode is not enabled, exit
|
|
253
725
|
if not watch:
|
|
@@ -257,7 +729,7 @@ def gen_tsi(app_path: str, file_path: StreamWriter, watch: bool, src_dir: str) -
|
|
|
257
729
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
258
730
|
from watchdog.observers import Observer
|
|
259
731
|
except ImportError:
|
|
260
|
-
|
|
732
|
+
click.echo(
|
|
261
733
|
"Watchdog is required for watch mode. Install it with: pip install watchdog",
|
|
262
734
|
file=sys.stderr,
|
|
263
735
|
)
|
|
@@ -272,22 +744,52 @@ def gen_tsi(app_path: str, file_path: StreamWriter, watch: bool, src_dir: str) -
|
|
|
272
744
|
else str(event.src_path)
|
|
273
745
|
)
|
|
274
746
|
if not event.is_directory and src_path.endswith(".py"):
|
|
275
|
-
|
|
276
|
-
|
|
747
|
+
click.echo(f"File changed: {src_path}")
|
|
748
|
+
# Create a completely detached process to ensure classes are reloaded
|
|
749
|
+
process = multiprocessing.get_context("spawn").Process(
|
|
750
|
+
target=generate_interfaces,
|
|
751
|
+
args=(app_path, file_path, False, post_process),
|
|
752
|
+
daemon=False, # Non-daemon to ensure it completes
|
|
753
|
+
)
|
|
754
|
+
process.start()
|
|
755
|
+
# Don't join to keep it detached from main process
|
|
277
756
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
757
|
+
def _run_generator_in_separate_process(
|
|
758
|
+
self, app_path: str, file_path: str
|
|
759
|
+
) -> None:
|
|
760
|
+
# Using Python executable to start a completely new process
|
|
761
|
+
# This ensures all modules are freshly imported
|
|
762
|
+
generate_interfaces(app_path, file_path)
|
|
763
|
+
# cmd = [
|
|
764
|
+
# sys.executable,
|
|
765
|
+
# "-c",
|
|
766
|
+
# (
|
|
767
|
+
# f"import sys; sys.path.extend({sys.path}); "
|
|
768
|
+
# f"from jararaca.cli import generate_interfaces; "
|
|
769
|
+
# f"generate_interfaces('{app_path}', '{file_path}')"
|
|
770
|
+
# ),
|
|
771
|
+
# ]
|
|
772
|
+
# import subprocess
|
|
282
773
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
observer
|
|
289
|
-
|
|
290
|
-
|
|
774
|
+
# subprocess.run(cmd, check=False)
|
|
775
|
+
|
|
776
|
+
@typing.no_type_check
|
|
777
|
+
def start_watchdog() -> None:
|
|
778
|
+
|
|
779
|
+
observer: "BaseObserver" = Observer()
|
|
780
|
+
observer.schedule(PyFileChangeHandler(), src_dir, recursive=True)
|
|
781
|
+
observer.start()
|
|
782
|
+
|
|
783
|
+
click.echo(f"Watching for changes in {os.path.abspath(src_dir)}...")
|
|
784
|
+
try:
|
|
785
|
+
while True:
|
|
786
|
+
time.sleep(1)
|
|
787
|
+
except KeyboardInterrupt:
|
|
788
|
+
observer.stop()
|
|
789
|
+
click.echo("Watch mode stopped")
|
|
790
|
+
observer.join()
|
|
791
|
+
|
|
792
|
+
start_watchdog()
|
|
291
793
|
|
|
292
794
|
|
|
293
795
|
def camel_case_to_snake_case(name: str) -> str:
|
|
@@ -303,10 +805,11 @@ def camel_case_to_pascal_case(name: str) -> str:
|
|
|
303
805
|
|
|
304
806
|
|
|
305
807
|
@cli.command()
|
|
306
|
-
@click.argument("entity_name", type=click.STRING)
|
|
808
|
+
@click.argument("entity_name", type=click.STRING, envvar="ENTITY_NAME")
|
|
307
809
|
@click.argument(
|
|
308
810
|
"file_path",
|
|
309
811
|
type=click.File("w"),
|
|
812
|
+
envvar="FILE_PATH",
|
|
310
813
|
)
|
|
311
814
|
def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
|
|
312
815
|
|
|
@@ -323,3 +826,237 @@ def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
|
|
|
323
826
|
entityNameKebabCase=entity_kebab_case,
|
|
324
827
|
)
|
|
325
828
|
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@cli.command()
|
|
832
|
+
@click.argument(
|
|
833
|
+
"app_path",
|
|
834
|
+
type=str,
|
|
835
|
+
envvar="APP_PATH",
|
|
836
|
+
)
|
|
837
|
+
@click.option(
|
|
838
|
+
"--broker-url",
|
|
839
|
+
type=str,
|
|
840
|
+
envvar="BROKER_URL",
|
|
841
|
+
help="Broker URL (e.g., amqp://guest:guest@localhost/) [env: BROKER_URL]",
|
|
842
|
+
)
|
|
843
|
+
@click.option(
|
|
844
|
+
"-i",
|
|
845
|
+
"--interactive-mode",
|
|
846
|
+
is_flag=True,
|
|
847
|
+
default=False,
|
|
848
|
+
envvar="INTERACTIVE_MODE",
|
|
849
|
+
help="Enable interactive mode for queue declaration (confirm before deleting existing queues)",
|
|
850
|
+
)
|
|
851
|
+
@click.option(
|
|
852
|
+
"-f",
|
|
853
|
+
"--force",
|
|
854
|
+
is_flag=True,
|
|
855
|
+
default=False,
|
|
856
|
+
envvar="FORCE",
|
|
857
|
+
help="Force recreation by deleting existing exchanges and queues before declaring them",
|
|
858
|
+
)
|
|
859
|
+
def declare(
|
|
860
|
+
app_path: str,
|
|
861
|
+
broker_url: str,
|
|
862
|
+
force: bool,
|
|
863
|
+
interactive_mode: bool,
|
|
864
|
+
) -> None:
|
|
865
|
+
"""
|
|
866
|
+
Declare RabbitMQ infrastructure (exchanges and queues) for message handlers and schedulers.
|
|
867
|
+
|
|
868
|
+
This command pre-declares the necessary exchanges and queues for message handlers and schedulers,
|
|
869
|
+
without starting the actual consumption processes.
|
|
870
|
+
|
|
871
|
+
Environment variables:
|
|
872
|
+
- BROKER_URL: Broker URL (e.g., amqp://guest:guest@localhost/)
|
|
873
|
+
- EXCHANGE: Exchange name (defaults to 'jararaca_ex')
|
|
874
|
+
|
|
875
|
+
Examples:
|
|
876
|
+
|
|
877
|
+
\b
|
|
878
|
+
# Declare infrastructure
|
|
879
|
+
jararaca declare myapp:app --broker-url amqp://guest:guest@localhost/
|
|
880
|
+
|
|
881
|
+
\b
|
|
882
|
+
# Force recreation of queues and exchanges
|
|
883
|
+
jararaca declare myapp:app --broker-url amqp://guest:guest@localhost/ --force
|
|
884
|
+
|
|
885
|
+
\b
|
|
886
|
+
# Use environment variables
|
|
887
|
+
export BROKER_URL="amqp://guest:guest@localhost/"
|
|
888
|
+
export EXCHANGE="my_exchange"
|
|
889
|
+
jararaca declare myapp:app
|
|
890
|
+
"""
|
|
891
|
+
|
|
892
|
+
app = find_microservice_by_module_path(app_path)
|
|
893
|
+
|
|
894
|
+
async def run_declarations() -> None:
|
|
895
|
+
if not broker_url:
|
|
896
|
+
click.echo(
|
|
897
|
+
"ERROR: --broker-url is required or set BROKER_URL environment variable",
|
|
898
|
+
err=True,
|
|
899
|
+
)
|
|
900
|
+
return
|
|
901
|
+
|
|
902
|
+
try:
|
|
903
|
+
# Create the broker URL with exchange parameter
|
|
904
|
+
|
|
905
|
+
click.echo(f"→ Declaring worker infrastructure (URL: {broker_url})")
|
|
906
|
+
await declare_worker_infrastructure(
|
|
907
|
+
broker_url, app, force, interactive_mode
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
click.echo("✓ Workers infrastructure declared successfully!")
|
|
911
|
+
except Exception as e:
|
|
912
|
+
click.echo(f"ERROR: Failed to declare infrastructure: {e}", err=True)
|
|
913
|
+
raise
|
|
914
|
+
|
|
915
|
+
asyncio.run(run_declarations())
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def run_worker_process(
|
|
919
|
+
app_path: str, broker_url: str, backend_url: str, handlers: str | None
|
|
920
|
+
) -> None:
|
|
921
|
+
"""Run a worker process with the given parameters."""
|
|
922
|
+
app = find_microservice_by_module_path(app_path)
|
|
923
|
+
|
|
924
|
+
# Parse handler names if provided
|
|
925
|
+
handler_names: set[str] | None = None
|
|
926
|
+
if handlers:
|
|
927
|
+
handler_names = {name.strip() for name in handlers.split(",") if name.strip()}
|
|
928
|
+
|
|
929
|
+
click.echo(f"Starting worker for {app_path}...")
|
|
930
|
+
worker_mod.MessageBusWorker(
|
|
931
|
+
app=app,
|
|
932
|
+
broker_url=broker_url,
|
|
933
|
+
backend_url=backend_url,
|
|
934
|
+
handler_names=handler_names,
|
|
935
|
+
).start_sync()
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def run_beat_process(
|
|
939
|
+
app_path: str, interval: int, broker_url: str, backend_url: str, actions: str | None
|
|
940
|
+
) -> None:
|
|
941
|
+
"""Run a beat scheduler process with the given parameters."""
|
|
942
|
+
app = find_microservice_by_module_path(app_path)
|
|
943
|
+
|
|
944
|
+
# Parse scheduler names if provided
|
|
945
|
+
scheduler_names: set[str] | None = None
|
|
946
|
+
if actions:
|
|
947
|
+
scheduler_names = {name.strip() for name in actions.split(",") if name.strip()}
|
|
948
|
+
|
|
949
|
+
click.echo(f"Starting beat scheduler for {app_path}...")
|
|
950
|
+
beat_worker = BeatWorker(
|
|
951
|
+
app=app,
|
|
952
|
+
interval=interval,
|
|
953
|
+
backend_url=backend_url,
|
|
954
|
+
broker_url=broker_url,
|
|
955
|
+
scheduled_action_names=scheduler_names,
|
|
956
|
+
)
|
|
957
|
+
beat_worker.run()
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def run_with_reload_watcher(
|
|
961
|
+
process_args: dict[str, Any],
|
|
962
|
+
process_target: Callable[..., Any],
|
|
963
|
+
src_dir: str = "src",
|
|
964
|
+
max_graceful_shutdown_seconds: int = 20,
|
|
965
|
+
) -> None:
|
|
966
|
+
"""
|
|
967
|
+
Run a process with a file watcher that will restart it when Python files change.
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
process_args: Arguments to pass to the process function
|
|
971
|
+
process_target: The function to run as the process
|
|
972
|
+
src_dir: The directory to watch for changes
|
|
973
|
+
"""
|
|
974
|
+
try:
|
|
975
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
976
|
+
from watchdog.observers import Observer
|
|
977
|
+
except ImportError:
|
|
978
|
+
click.echo(
|
|
979
|
+
"Watchdog is required for reload mode. Install it with: pip install watchdog",
|
|
980
|
+
file=sys.stderr,
|
|
981
|
+
)
|
|
982
|
+
return
|
|
983
|
+
|
|
984
|
+
# Run the initial process
|
|
985
|
+
process = multiprocessing.get_context("spawn").Process(
|
|
986
|
+
target=process_target,
|
|
987
|
+
kwargs=process_args,
|
|
988
|
+
daemon=False, # Non-daemon to ensure it completes properly
|
|
989
|
+
)
|
|
990
|
+
process.start() # Set up file system event handler
|
|
991
|
+
|
|
992
|
+
class PyFileChangeHandler(FileSystemEventHandler):
|
|
993
|
+
def __init__(self) -> None:
|
|
994
|
+
self.last_modified_time = time.time()
|
|
995
|
+
self.debounce_seconds = 1.0 # Debounce to avoid multiple restarts
|
|
996
|
+
self.active_process = process
|
|
997
|
+
|
|
998
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
999
|
+
src_path = (
|
|
1000
|
+
event.src_path
|
|
1001
|
+
if isinstance(event.src_path, str)
|
|
1002
|
+
else str(event.src_path)
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
# Ignore non-Python files and directories
|
|
1006
|
+
if event.is_directory or not src_path.endswith(".py"):
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1009
|
+
# Debounce to avoid multiple restarts
|
|
1010
|
+
current_time = time.time()
|
|
1011
|
+
if current_time - self.last_modified_time < self.debounce_seconds:
|
|
1012
|
+
return
|
|
1013
|
+
self.last_modified_time = current_time
|
|
1014
|
+
|
|
1015
|
+
click.echo(f"Detected change in {src_path}")
|
|
1016
|
+
click.echo("Restarting process...")
|
|
1017
|
+
|
|
1018
|
+
# Terminate the current process
|
|
1019
|
+
if self.active_process and self.active_process.is_alive():
|
|
1020
|
+
self.active_process.terminate()
|
|
1021
|
+
self.active_process.join(timeout=max_graceful_shutdown_seconds)
|
|
1022
|
+
|
|
1023
|
+
# If process doesn't terminate, kill it
|
|
1024
|
+
if self.active_process.is_alive():
|
|
1025
|
+
click.echo("Process did not terminate gracefully, killing it")
|
|
1026
|
+
self.active_process.kill()
|
|
1027
|
+
self.active_process.join()
|
|
1028
|
+
|
|
1029
|
+
# Create a new process
|
|
1030
|
+
|
|
1031
|
+
self.active_process = multiprocessing.get_context("spawn").Process(
|
|
1032
|
+
target=process_target,
|
|
1033
|
+
kwargs=process_args,
|
|
1034
|
+
daemon=False,
|
|
1035
|
+
)
|
|
1036
|
+
self.active_process.start()
|
|
1037
|
+
|
|
1038
|
+
@typing.no_type_check
|
|
1039
|
+
def start_watchdog() -> None:
|
|
1040
|
+
|
|
1041
|
+
# Set up observer
|
|
1042
|
+
observer = Observer()
|
|
1043
|
+
observer.schedule(PyFileChangeHandler(), src_dir, recursive=True)
|
|
1044
|
+
observer.start()
|
|
1045
|
+
|
|
1046
|
+
click.echo(f"Watching for changes in {os.path.abspath(src_dir)}...")
|
|
1047
|
+
try:
|
|
1048
|
+
while True:
|
|
1049
|
+
time.sleep(1)
|
|
1050
|
+
except KeyboardInterrupt:
|
|
1051
|
+
observer.stop()
|
|
1052
|
+
if process.is_alive():
|
|
1053
|
+
click.echo("Stopping process...")
|
|
1054
|
+
process.terminate()
|
|
1055
|
+
process.join(timeout=max_graceful_shutdown_seconds)
|
|
1056
|
+
if process.is_alive():
|
|
1057
|
+
process.kill()
|
|
1058
|
+
process.join()
|
|
1059
|
+
click.echo("Reload mode stopped")
|
|
1060
|
+
observer.join()
|
|
1061
|
+
|
|
1062
|
+
start_watchdog()
|