jararaca 0.3.9__py3-none-any.whl → 0.3.11__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.

Potentially problematic release.


This version of jararaca might be problematic. Click here for more details.

Files changed (35) hide show
  1. jararaca/__init__.py +76 -5
  2. jararaca/cli.py +460 -116
  3. jararaca/core/uow.py +17 -12
  4. jararaca/messagebus/decorators.py +33 -30
  5. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +30 -2
  6. jararaca/messagebus/interceptors/publisher_interceptor.py +7 -3
  7. jararaca/messagebus/publisher.py +14 -6
  8. jararaca/messagebus/worker.py +1102 -88
  9. jararaca/microservice.py +137 -34
  10. jararaca/observability/decorators.py +7 -3
  11. jararaca/observability/interceptor.py +4 -2
  12. jararaca/observability/providers/otel.py +14 -10
  13. jararaca/persistence/base.py +2 -1
  14. jararaca/persistence/interceptors/aiosqa_interceptor.py +167 -16
  15. jararaca/persistence/utilities.py +32 -20
  16. jararaca/presentation/decorators.py +96 -10
  17. jararaca/presentation/server.py +31 -4
  18. jararaca/presentation/websocket/context.py +30 -4
  19. jararaca/presentation/websocket/types.py +2 -2
  20. jararaca/presentation/websocket/websocket_interceptor.py +28 -4
  21. jararaca/reflect/__init__.py +0 -0
  22. jararaca/reflect/controller_inspect.py +75 -0
  23. jararaca/{tools → reflect}/metadata.py +25 -5
  24. jararaca/scheduler/{scheduler_v2.py → beat_worker.py} +49 -53
  25. jararaca/scheduler/decorators.py +55 -20
  26. jararaca/tools/app_config/interceptor.py +4 -2
  27. jararaca/utils/rabbitmq_utils.py +259 -5
  28. jararaca/utils/retry.py +141 -0
  29. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/METADATA +2 -1
  30. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/RECORD +33 -32
  31. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/WHEEL +1 -1
  32. jararaca/messagebus/worker_v2.py +0 -617
  33. jararaca/scheduler/scheduler.py +0 -161
  34. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/LICENSE +0 -0
  35. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/entry_points.txt +0 -0
jararaca/cli.py CHANGED
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import importlib
2
3
  import importlib.resources
3
4
  import multiprocessing
@@ -7,22 +8,28 @@ import time
7
8
  from codecs import StreamWriter
8
9
  from pathlib import Path
9
10
  from typing import Any
10
- from urllib.parse import urlparse, urlunsplit
11
+ from urllib.parse import parse_qs, urlparse
11
12
 
13
+ import aio_pika
12
14
  import click
13
15
  import uvicorn
14
- from mako.template import Template # type: ignore
16
+ from mako.template import Template
15
17
 
16
- from jararaca.messagebus import worker as worker_v1
17
- from jararaca.messagebus import worker_v2 as worker_v2_mod
18
+ from jararaca.messagebus import worker as worker_mod
19
+ from jararaca.messagebus.decorators import MessageBusController, MessageHandler
18
20
  from jararaca.microservice import Microservice
19
21
  from jararaca.presentation.http_microservice import HttpMicroservice
20
22
  from jararaca.presentation.server import create_http_server
21
- from jararaca.scheduler.scheduler import Scheduler
22
- from jararaca.scheduler.scheduler_v2 import SchedulerV2
23
+ from jararaca.reflect.controller_inspect import (
24
+ ControllerMemberReflect,
25
+ inspect_controller,
26
+ )
27
+ from jararaca.scheduler.beat_worker import BeatWorker
28
+ from jararaca.scheduler.decorators import ScheduledAction
23
29
  from jararaca.tools.typescript.interface_parser import (
24
30
  write_microservice_to_typescript_interface,
25
31
  )
32
+ from jararaca.utils.rabbitmq_utils import RabbitmqUtils
26
33
 
27
34
  LIBRARY_FILES_PATH = importlib.resources.files("jararaca.files")
28
35
  ENTITY_TEMPLATE_PATH = LIBRARY_FILES_PATH / "entity.py.mako"
@@ -67,89 +74,328 @@ def find_microservice_by_module_path(module_path: str) -> Microservice:
67
74
  return app
68
75
 
69
76
 
70
- @click.group()
71
- def cli() -> None:
72
- pass
77
+ # The v1 infrastructure declaration function has been removed as part of the CLI simplification
73
78
 
74
79
 
75
- @cli.command()
76
- @click.argument(
77
- "app_path",
78
- type=str,
79
- )
80
- @click.option(
81
- "--url",
82
- type=str,
83
- envvar="BROKER_URL",
84
- )
85
- @click.option(
86
- "--username",
87
- type=str,
88
- default=None,
89
- )
90
- @click.option(
91
- "--password",
92
- type=str,
93
- default=None,
94
- )
95
- @click.option(
96
- "--exchange",
97
- type=str,
98
- default="jararaca_ex",
99
- )
100
- @click.option(
101
- "--prefetch-count",
102
- type=int,
103
- default=1,
104
- )
105
- @click.option(
106
- "--passive-declare",
107
- is_flag=True,
108
- default=False,
109
- )
110
- def worker(
111
- app_path: str,
112
- url: str,
113
- username: str | None,
114
- password: str | None,
80
+ async def declare_worker_infrastructure(
81
+ broker_url: str,
82
+ app: Microservice,
83
+ force: bool = False,
84
+ interactive_mode: bool = False,
85
+ ) -> None:
86
+ """
87
+ Declare the infrastructure (exchanges and queues) for worker.
88
+ """
89
+ parsed_url = urlparse(broker_url)
90
+ if parsed_url.scheme not in ["amqp", "amqps"]:
91
+ raise ValueError(f"Unsupported broker URL scheme: {parsed_url.scheme}")
92
+
93
+ if not parsed_url.query:
94
+ raise ValueError("Query string must be set for AMQP URLs")
95
+
96
+ query_params = parse_qs(parsed_url.query)
97
+
98
+ if "exchange" not in query_params or not query_params["exchange"]:
99
+ raise ValueError("Exchange must be set in the query string")
100
+
101
+ exchange = query_params["exchange"][0]
102
+
103
+ # Create a connection that will be used to create channels for each operation
104
+ connection = await aio_pika.connect(broker_url)
105
+
106
+ try:
107
+ # Step 1: Setup infrastructure (exchanges and dead letter queues)
108
+ # Creating a dedicated channel for infrastructure setup
109
+ await setup_infrastructure(connection, exchange, force, interactive_mode)
110
+
111
+ # Step 2: Declare all message handlers and scheduled actions queues
112
+ # Creating a dedicated channel for controller queues
113
+ await declare_controller_queues(
114
+ connection, app, exchange, force, interactive_mode
115
+ )
116
+ finally:
117
+ # Always close the connection in the finally block
118
+ await connection.close()
119
+
120
+
121
+ async def setup_infrastructure(
122
+ connection: aio_pika.abc.AbstractConnection,
115
123
  exchange: str,
116
- prefetch_count: int,
117
- passive_declare: bool,
124
+ force: bool = False,
125
+ interactive_mode: bool = False,
118
126
  ) -> None:
127
+ """
128
+ Setup the basic infrastructure (exchanges and dead letter queues).
129
+ """
130
+ # Check if infrastructure exists
131
+ infrastructure_exists = await check_infrastructure_exists(connection, exchange)
132
+
133
+ # If it exists and force or user confirms, delete it
134
+ if not infrastructure_exists and should_recreate_infrastructure(
135
+ force,
136
+ interactive_mode,
137
+ f"Existing infrastructure found for exchange '{exchange}'. Delete and recreate?",
138
+ ):
139
+ await delete_infrastructure(connection, exchange)
140
+
141
+ # Try to declare required infrastructure
142
+ await declare_infrastructure(connection, exchange, force, interactive_mode)
143
+
144
+
145
+ async def check_infrastructure_exists(
146
+ connection: aio_pika.abc.AbstractConnection, exchange: str
147
+ ) -> bool:
148
+ """
149
+ Check if the infrastructure exists by trying passive declarations.
150
+ """
151
+ # Create a dedicated channel for checking infrastructure existence using async context manager
152
+ async with connection.channel() as channel:
153
+ try:
154
+ await RabbitmqUtils.declare_main_exchange(
155
+ channel=channel,
156
+ exchange_name=exchange,
157
+ passive=True,
158
+ )
159
+ await RabbitmqUtils.declare_dl_exchange(channel=channel, passive=True)
160
+ await RabbitmqUtils.declare_dl_queue(channel=channel, passive=True)
161
+ return True
162
+ except Exception:
163
+ # Infrastructure doesn't exist, which is fine for fresh setup
164
+ return False
119
165
 
120
- app = find_microservice_by_module_path(app_path)
121
166
 
122
- parsed_url = urlparse(url)
167
+ def should_recreate_infrastructure(
168
+ force: bool, interactive_mode: bool, confirmation_message: str
169
+ ) -> bool:
170
+ """
171
+ Determine if infrastructure should be recreated based on force flag and user input.
172
+ """
173
+ return force or (interactive_mode and click.confirm(confirmation_message))
123
174
 
124
- if password is not None:
125
- parsed_url = urlparse(
126
- urlunsplit(
127
- parsed_url._replace(
128
- netloc=f"{parsed_url.username or ''}:{password}@{parsed_url.netloc}"
129
- )
175
+
176
+ async def delete_infrastructure(
177
+ connection: aio_pika.abc.AbstractConnection, exchange: str
178
+ ) -> None:
179
+ """
180
+ Delete existing infrastructure (exchanges and queues).
181
+ """
182
+ # Create a dedicated channel for deleting infrastructure using async context manager
183
+ async with connection.channel() as channel:
184
+ click.echo(f"→ Deleting existing infrastructure for exchange: {exchange}")
185
+ await RabbitmqUtils.delete_exchange(channel, exchange)
186
+ await RabbitmqUtils.delete_exchange(channel, RabbitmqUtils.DEAD_LETTER_EXCHANGE)
187
+ await RabbitmqUtils.delete_queue(channel, RabbitmqUtils.DEAD_LETTER_QUEUE)
188
+
189
+
190
+ async def declare_infrastructure(
191
+ connection: aio_pika.abc.AbstractConnection,
192
+ exchange: str,
193
+ force: bool,
194
+ interactive_mode: bool,
195
+ ) -> None:
196
+ """
197
+ Declare the required infrastructure (exchanges and dead letter queues).
198
+ """
199
+ # Using async context manager for channel creation
200
+ async with connection.channel() as channel:
201
+ try:
202
+ # Declare main exchange
203
+ await RabbitmqUtils.declare_main_exchange(
204
+ channel=channel,
205
+ exchange_name=exchange,
206
+ passive=False,
130
207
  )
131
- )
132
208
 
133
- if username is not None:
134
- parsed_url = urlparse(
135
- urlunsplit(
136
- parsed_url._replace(
137
- netloc=f"{username}{':%s' % password if password is not None else ''}@{parsed_url.netloc}"
138
- )
209
+ # Declare dead letter exchange and queue
210
+ dlx = await RabbitmqUtils.declare_dl_exchange(
211
+ channel=channel, passive=False
212
+ )
213
+ dlq = await RabbitmqUtils.declare_dl_queue(channel=channel, passive=False)
214
+ await dlq.bind(dlx, routing_key=RabbitmqUtils.DEAD_LETTER_EXCHANGE)
215
+
216
+ except Exception as e:
217
+ click.echo(f"Error during exchange declaration: {e}")
218
+
219
+ # If interactive mode and user confirms, or if forced, try again after deletion
220
+ if should_recreate_infrastructure(
221
+ force, interactive_mode, "Delete existing infrastructure and recreate?"
222
+ ):
223
+ # Delete infrastructure with a new channel
224
+ await delete_infrastructure(connection, exchange)
225
+
226
+ # Try again with a new channel
227
+ async with connection.channel() as new_channel:
228
+ # Try again after deletion
229
+ await RabbitmqUtils.declare_main_exchange(
230
+ channel=new_channel,
231
+ exchange_name=exchange,
232
+ passive=False,
233
+ )
234
+ dlx = await RabbitmqUtils.declare_dl_exchange(
235
+ channel=new_channel, passive=False
236
+ )
237
+ dlq = await RabbitmqUtils.declare_dl_queue(
238
+ channel=new_channel, passive=False
239
+ )
240
+ await dlq.bind(dlx, routing_key=RabbitmqUtils.DEAD_LETTER_EXCHANGE)
241
+ elif force:
242
+ # If force is true but recreation failed, propagate the error
243
+ raise
244
+ else:
245
+ click.echo("Skipping main exchange declaration due to error")
246
+
247
+
248
+ async def declare_controller_queues(
249
+ connection: aio_pika.abc.AbstractConnection,
250
+ app: Microservice,
251
+ exchange: str,
252
+ force: bool,
253
+ interactive_mode: bool,
254
+ ) -> None:
255
+ """
256
+ Declare all message handler and scheduled action queues for controllers.
257
+ """
258
+ for instance_type in app.controllers:
259
+ controller_spec = MessageBusController.get_messagebus(instance_type)
260
+ if controller_spec is None:
261
+ continue
262
+
263
+ _, members = inspect_controller(instance_type)
264
+
265
+ # Process each member (method) in the controller
266
+ for _, member in members.items():
267
+ # Check if it's a message handler
268
+ await declare_message_handler_queue(
269
+ connection, member, exchange, force, interactive_mode
270
+ )
271
+
272
+ # Check if it's a scheduled action
273
+ await declare_scheduled_action_queue(
274
+ connection, member, exchange, force, interactive_mode
139
275
  )
276
+
277
+
278
+ async def declare_message_handler_queue(
279
+ connection: aio_pika.abc.AbstractConnection,
280
+ member: ControllerMemberReflect,
281
+ exchange: str,
282
+ force: bool,
283
+ interactive_mode: bool,
284
+ ) -> None:
285
+ """
286
+ Declare a queue for a message handler if the member is one.
287
+ """
288
+ message_handler = MessageHandler.get_message_incoming(member.member_function)
289
+ if message_handler is not None:
290
+ queue_name = f"{message_handler.message_type.MESSAGE_TOPIC}.{member.member_function.__module__}.{member.member_function.__qualname__}"
291
+ routing_key = f"{message_handler.message_type.MESSAGE_TOPIC}.#"
292
+
293
+ await declare_and_bind_queue(
294
+ connection,
295
+ queue_name,
296
+ routing_key,
297
+ exchange,
298
+ force,
299
+ interactive_mode,
300
+ is_scheduled_action=False,
140
301
  )
141
302
 
142
- url = parsed_url.geturl()
143
303
 
144
- config = worker_v1.AioPikaWorkerConfig(
145
- url=url,
146
- exchange=exchange,
147
- prefetch_count=prefetch_count,
148
- )
304
+ async def declare_scheduled_action_queue(
305
+ connection: aio_pika.abc.AbstractConnection,
306
+ member: ControllerMemberReflect,
307
+ exchange: str,
308
+ force: bool,
309
+ interactive_mode: bool,
310
+ ) -> None:
311
+ """
312
+ Declare a queue for a scheduled action if the member is one.
313
+ """
314
+ scheduled_action = ScheduledAction.get_scheduled_action(member.member_function)
315
+ if scheduled_action is not None:
316
+ queue_name = (
317
+ f"{member.member_function.__module__}.{member.member_function.__qualname__}"
318
+ )
319
+ routing_key = queue_name
320
+
321
+ await declare_and_bind_queue(
322
+ connection,
323
+ queue_name,
324
+ routing_key,
325
+ exchange,
326
+ force,
327
+ interactive_mode,
328
+ is_scheduled_action=True,
329
+ )
330
+
331
+
332
+ async def declare_and_bind_queue(
333
+ connection: aio_pika.abc.AbstractConnection,
334
+ queue_name: str,
335
+ routing_key: str,
336
+ exchange: str,
337
+ force: bool,
338
+ interactive_mode: bool,
339
+ is_scheduled_action: bool,
340
+ ) -> None:
341
+ """
342
+ Declare and bind a queue to the exchange with the given routing key.
343
+ """
344
+ queue_type = "scheduled action" if is_scheduled_action else "message handler"
345
+
346
+ # Using async context manager for channel creation
347
+ async with connection.channel() as channel:
348
+ try:
349
+ # Try to declare queue using the appropriate method
350
+ if is_scheduled_action:
351
+ queue = await RabbitmqUtils.declare_scheduled_action_queue(
352
+ channel=channel, queue_name=queue_name, passive=False
353
+ )
354
+ else:
355
+ queue = await RabbitmqUtils.declare_worker_queue(
356
+ channel=channel, queue_name=queue_name, passive=False
357
+ )
358
+
359
+ # Bind the queue to the exchange
360
+ await queue.bind(exchange=exchange, routing_key=routing_key)
361
+ click.echo(
362
+ f"✓ Declared {queue_type} queue: {queue_name} (routing key: {routing_key})"
363
+ )
364
+ except Exception as e:
365
+ click.echo(f"⚠ Error declaring {queue_type} queue {queue_name}: {e}")
366
+
367
+ # If interactive mode and user confirms, or if forced, recreate the queue
368
+ if force or (
369
+ interactive_mode
370
+ and click.confirm(f"Delete and recreate queue {queue_name}?")
371
+ ):
372
+ # Create a new channel for deletion and recreation
373
+ async with connection.channel() as new_channel:
374
+ # Delete the queue
375
+ await RabbitmqUtils.delete_queue(new_channel, queue_name)
376
+
377
+ # Try to declare queue again using the appropriate method
378
+ if is_scheduled_action:
379
+ queue = await RabbitmqUtils.declare_scheduled_action_queue(
380
+ channel=new_channel, queue_name=queue_name, passive=False
381
+ )
382
+ else:
383
+ queue = await RabbitmqUtils.declare_worker_queue(
384
+ channel=new_channel, queue_name=queue_name, passive=False
385
+ )
386
+
387
+ # Bind the queue to the exchange
388
+ await queue.bind(exchange=exchange, routing_key=routing_key)
389
+ click.echo(
390
+ f"✓ Recreated {queue_type} queue: {queue_name} (routing key: {routing_key})"
391
+ )
392
+ else:
393
+ click.echo(f"⚠ Skipping {queue_type} queue {queue_name}")
149
394
 
150
- worker_v1.MessageBusWorker(app, config=config).start_sync(
151
- passive_declare=passive_declare,
152
- )
395
+
396
+ @click.group()
397
+ def cli() -> None:
398
+ pass
153
399
 
154
400
 
155
401
  @cli.command()
@@ -162,20 +408,37 @@ def worker(
162
408
  "--broker-url",
163
409
  type=str,
164
410
  envvar="BROKER_URL",
411
+ required=True,
412
+ help="The URL for the message broker",
165
413
  )
166
414
  @click.option(
167
415
  "--backend-url",
168
416
  type=str,
169
417
  envvar="BACKEND_URL",
418
+ required=True,
419
+ help="The URL for the message broker backend",
170
420
  )
171
- def worker_v2(app_path: str, broker_url: str, backend_url: str) -> None:
172
-
421
+ @click.option(
422
+ "--handlers",
423
+ type=str,
424
+ help="Comma-separated list of handler names to listen to. If not specified, all handlers will be used.",
425
+ )
426
+ def worker(
427
+ app_path: str, broker_url: str, backend_url: str, handlers: str | None
428
+ ) -> None:
429
+ """Start a message bus worker that processes asynchronous messages from a message queue."""
173
430
  app = find_microservice_by_module_path(app_path)
174
431
 
175
- worker_v2_mod.MessageBusWorker(
432
+ # Parse handler names if provided
433
+ handler_names: set[str] | None = None
434
+ if handlers:
435
+ handler_names = {name.strip() for name in handlers.split(",") if name.strip()}
436
+
437
+ worker_mod.MessageBusWorker(
176
438
  app=app,
177
439
  broker_url=broker_url,
178
440
  backend_url=backend_url,
441
+ handler_names=handler_names,
179
442
  ).start_sync()
180
443
 
181
444
 
@@ -218,25 +481,6 @@ def server(app_path: str, host: str, port: int) -> None:
218
481
  uvicorn.run(asgi_app, host=host, port=port)
219
482
 
220
483
 
221
- @cli.command()
222
- @click.argument(
223
- "app_path",
224
- type=str,
225
- )
226
- @click.option(
227
- "--interval",
228
- type=int,
229
- default=1,
230
- )
231
- def scheduler(
232
- app_path: str,
233
- interval: int,
234
- ) -> None:
235
- app = find_microservice_by_module_path(app_path)
236
-
237
- Scheduler(app, interval=interval).run()
238
-
239
-
240
484
  @cli.command()
241
485
  @click.argument(
242
486
  "app_path",
@@ -258,21 +502,34 @@ def scheduler(
258
502
  type=str,
259
503
  required=True,
260
504
  )
261
- def scheduler_v2(
505
+ @click.option(
506
+ "--actions",
507
+ type=str,
508
+ help="Comma-separated list of action names to run (only run actions with these names)",
509
+ )
510
+ def beat(
262
511
  interval: int,
263
512
  broker_url: str,
264
513
  backend_url: str,
265
514
  app_path: str,
515
+ actions: str | None = None,
266
516
  ) -> None:
267
517
 
268
518
  app = find_microservice_by_module_path(app_path)
269
- scheduler = SchedulerV2(
519
+
520
+ # Parse scheduler names if provided
521
+ scheduler_names: set[str] | None = None
522
+ if actions:
523
+ scheduler_names = {name.strip() for name in actions.split(",") if name.strip()}
524
+
525
+ beat_worker = BeatWorker(
270
526
  app=app,
271
527
  interval=interval,
272
528
  backend_url=backend_url,
273
529
  broker_url=broker_url,
530
+ scheduled_action_names=scheduler_names,
274
531
  )
275
- scheduler.run()
532
+ beat_worker.run()
276
533
 
277
534
 
278
535
  def generate_interfaces(
@@ -303,7 +560,7 @@ def generate_interfaces(
303
560
  file.write(content)
304
561
  file.flush()
305
562
 
306
- print(
563
+ click.echo(
307
564
  f"Generated TypeScript interfaces at {time.strftime('%H:%M:%S')} at {str(Path(file_path).absolute())}"
308
565
  )
309
566
 
@@ -311,19 +568,19 @@ def generate_interfaces(
311
568
  import subprocess
312
569
 
313
570
  try:
314
- print(f"Running post-process command: {post_process_cmd}")
571
+ click.echo(f"Running post-process command: {post_process_cmd}")
315
572
  subprocess.run(
316
573
  post_process_cmd.replace("{file}", file_path),
317
574
  shell=True,
318
575
  check=True,
319
576
  )
320
- print(f"Post-processing completed successfully")
577
+ click.echo(f"Post-processing completed successfully")
321
578
  except subprocess.CalledProcessError as e:
322
- print(f"Post-processing command failed: {e}", file=sys.stderr)
579
+ click.echo(f"Post-processing command failed: {e}", file=sys.stderr)
323
580
 
324
581
  return content
325
582
  except Exception as e:
326
- print(f"Error generating TypeScript interfaces: {e}", file=sys.stderr)
583
+ click.echo(f"Error generating TypeScript interfaces: {e}", file=sys.stderr)
327
584
  return ""
328
585
 
329
586
 
@@ -367,18 +624,20 @@ def gen_tsi(
367
624
  """Generate TypeScript interfaces from a Python microservice."""
368
625
 
369
626
  if stdout and watch:
370
- print(
627
+ click.echo(
371
628
  "Error: --watch and --stdout options cannot be used together",
372
629
  file=sys.stderr,
373
630
  )
374
631
  return
375
632
 
376
633
  if not file_path and not stdout:
377
- print("Error: either file_path or --stdout must be provided", file=sys.stderr)
634
+ click.echo(
635
+ "Error: either file_path or --stdout must be provided", file=sys.stderr
636
+ )
378
637
  return
379
638
 
380
639
  if post_process and stdout:
381
- print(
640
+ click.echo(
382
641
  "Error: --post-process and --stdout options cannot be used together",
383
642
  file=sys.stderr,
384
643
  )
@@ -388,7 +647,7 @@ def gen_tsi(
388
647
  content = generate_interfaces(app_path, file_path, stdout, post_process)
389
648
 
390
649
  if stdout:
391
- print(content)
650
+ click.echo(content)
392
651
  return
393
652
 
394
653
  # If watch mode is not enabled, exit
@@ -399,7 +658,7 @@ def gen_tsi(
399
658
  from watchdog.events import FileSystemEvent, FileSystemEventHandler
400
659
  from watchdog.observers import Observer
401
660
  except ImportError:
402
- print(
661
+ click.echo(
403
662
  "Watchdog is required for watch mode. Install it with: pip install watchdog",
404
663
  file=sys.stderr,
405
664
  )
@@ -414,7 +673,7 @@ def gen_tsi(
414
673
  else str(event.src_path)
415
674
  )
416
675
  if not event.is_directory and src_path.endswith(".py"):
417
- print(f"File changed: {src_path}")
676
+ click.echo(f"File changed: {src_path}")
418
677
  # Create a completely detached process to ensure classes are reloaded
419
678
  process = multiprocessing.get_context("spawn").Process(
420
679
  target=generate_interfaces,
@@ -445,16 +704,16 @@ def gen_tsi(
445
704
 
446
705
  # Set up observer
447
706
  observer = Observer()
448
- observer.schedule(PyFileChangeHandler(), src_dir, recursive=True)
449
- observer.start()
707
+ observer.schedule(PyFileChangeHandler(), src_dir, recursive=True) # type: ignore
708
+ observer.start() # type: ignore
450
709
 
451
- print(f"Watching for changes in {os.path.abspath(src_dir)}...")
710
+ click.echo(f"Watching for changes in {os.path.abspath(src_dir)}...")
452
711
  try:
453
712
  while True:
454
713
  time.sleep(1)
455
714
  except KeyboardInterrupt:
456
- observer.stop()
457
- print("Watch mode stopped")
715
+ observer.stop() # type: ignore
716
+ click.echo("Watch mode stopped")
458
717
  observer.join()
459
718
 
460
719
 
@@ -491,3 +750,88 @@ def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
491
750
  entityNameKebabCase=entity_kebab_case,
492
751
  )
493
752
  )
753
+
754
+
755
+ @cli.command()
756
+ @click.argument(
757
+ "app_path",
758
+ type=str,
759
+ envvar="APP_PATH",
760
+ )
761
+ @click.option(
762
+ "--broker-url",
763
+ type=str,
764
+ envvar="BROKER_URL",
765
+ help="Broker URL (e.g., amqp://guest:guest@localhost/) [env: BROKER_URL]",
766
+ )
767
+ @click.option(
768
+ "-i",
769
+ "--interactive-mode",
770
+ is_flag=True,
771
+ default=False,
772
+ help="Enable interactive mode for queue declaration (confirm before deleting existing queues)",
773
+ )
774
+ @click.option(
775
+ "-f",
776
+ "--force",
777
+ is_flag=True,
778
+ default=False,
779
+ help="Force recreation by deleting existing exchanges and queues before declaring them",
780
+ )
781
+ def declare(
782
+ app_path: str,
783
+ broker_url: str,
784
+ force: bool,
785
+ interactive_mode: bool,
786
+ ) -> None:
787
+ """
788
+ Declare RabbitMQ infrastructure (exchanges and queues) for message handlers and schedulers.
789
+
790
+ This command pre-declares the necessary exchanges and queues for message handlers and schedulers,
791
+ without starting the actual consumption processes.
792
+
793
+ Environment variables:
794
+ - BROKER_URL: Broker URL (e.g., amqp://guest:guest@localhost/)
795
+ - EXCHANGE: Exchange name (defaults to 'jararaca_ex')
796
+
797
+ Examples:
798
+
799
+ \b
800
+ # Declare infrastructure
801
+ jararaca declare myapp:app --broker-url amqp://guest:guest@localhost/
802
+
803
+ \b
804
+ # Force recreation of queues and exchanges
805
+ jararaca declare myapp:app --broker-url amqp://guest:guest@localhost/ --force
806
+
807
+ \b
808
+ # Use environment variables
809
+ export BROKER_URL="amqp://guest:guest@localhost/"
810
+ export EXCHANGE="my_exchange"
811
+ jararaca declare myapp:app
812
+ """
813
+
814
+ app = find_microservice_by_module_path(app_path)
815
+
816
+ async def run_declarations() -> None:
817
+ if not broker_url:
818
+ click.echo(
819
+ "ERROR: --broker-url is required or set BROKER_URL environment variable",
820
+ err=True,
821
+ )
822
+ return
823
+
824
+ try:
825
+ # Create the broker URL with exchange parameter
826
+
827
+ click.echo(f"→ Declaring worker infrastructure (URL: {broker_url})")
828
+ await declare_worker_infrastructure(
829
+ broker_url, app, force, interactive_mode
830
+ )
831
+
832
+ click.echo("✓ Workers infrastructure declared successfully!")
833
+ except Exception as e:
834
+ click.echo(f"ERROR: Failed to declare infrastructure: {e}", err=True)
835
+ raise
836
+
837
+ asyncio.run(run_declarations())