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.
Files changed (91) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +267 -15
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +106 -0
  5. jararaca/broker_backend/mapper.py +25 -0
  6. jararaca/broker_backend/redis_broker_backend.py +168 -0
  7. jararaca/cli.py +840 -103
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +4 -0
  11. jararaca/core/uow.py +55 -16
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/lifecycle.py +6 -2
  15. jararaca/messagebus/__init__.py +5 -1
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +90 -85
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
  23. jararaca/messagebus/message.py +31 -0
  24. jararaca/messagebus/publisher.py +47 -4
  25. jararaca/messagebus/worker.py +1615 -135
  26. jararaca/microservice.py +248 -36
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +177 -16
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +8 -2
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +213 -18
  34. jararaca/persistence/base.py +40 -3
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
  38. jararaca/persistence/interceptors/constants.py +5 -0
  39. jararaca/persistence/interceptors/decorators.py +50 -0
  40. jararaca/persistence/session.py +3 -0
  41. jararaca/persistence/sort_filter.py +4 -0
  42. jararaca/persistence/utilities.py +74 -32
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +170 -82
  45. jararaca/presentation/exceptions.py +23 -0
  46. jararaca/presentation/hooks.py +4 -0
  47. jararaca/presentation/http_microservice.py +4 -0
  48. jararaca/presentation/server.py +120 -41
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +34 -4
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +6 -2
  55. jararaca/presentation/websocket/websocket_interceptor.py +74 -23
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +81 -0
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +76 -0
  60. jararaca/rpc/__init__.py +3 -0
  61. jararaca/rpc/http/__init__.py +101 -0
  62. jararaca/rpc/http/backends/__init__.py +14 -0
  63. jararaca/rpc/http/backends/httpx.py +43 -9
  64. jararaca/rpc/http/backends/otel.py +4 -0
  65. jararaca/rpc/http/decorators.py +378 -113
  66. jararaca/rpc/http/httpx.py +3 -0
  67. jararaca/scheduler/__init__.py +3 -0
  68. jararaca/scheduler/beat_worker.py +758 -0
  69. jararaca/scheduler/decorators.py +89 -28
  70. jararaca/scheduler/types.py +11 -0
  71. jararaca/tools/app_config/__init__.py +3 -0
  72. jararaca/tools/app_config/decorators.py +7 -19
  73. jararaca/tools/app_config/interceptor.py +10 -4
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1126 -189
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +372 -0
  79. jararaca/utils/retry.py +148 -0
  80. jararaca-0.4.0a5.dist-info/LICENSE +674 -0
  81. jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  82. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca/messagebus/types.py +0 -30
  87. jararaca/scheduler/scheduler.py +0 -154
  88. jararaca/tools/metadata.py +0 -47
  89. jararaca-0.2.37a12.dist-info/RECORD +0 -63
  90. /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
  91. {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 typing import Any
8
- from urllib.parse import urlparse, urlunsplit
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 # type: ignore
22
+ from mako.template import Template
13
23
 
14
- from jararaca.messagebus.worker import AioPikaWorkerConfig, MessageBusWorker
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.scheduler.scheduler import Scheduler, SchedulerBackend, SchedulerConfig
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
- raise ImportError("App module not found") from e
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
- default="amqp://guest:guest@localhost/",
420
+ envvar="BROKER_URL",
421
+ required=True,
422
+ help="The URL for the message broker",
77
423
  )
78
424
  @click.option(
79
- "--username",
425
+ "--backend-url",
80
426
  type=str,
81
- default=None,
427
+ envvar="BACKEND_URL",
428
+ required=True,
429
+ help="The URL for the message broker backend",
82
430
  )
83
431
  @click.option(
84
- "--password",
432
+ "--handlers",
85
433
  type=str,
86
- default=None,
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
- "--exchange",
90
- type=str,
91
- default="jararaca_ex",
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
- "--queue",
95
- type=str,
96
- default="jararaca_q",
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
- "--prefetch-count",
451
+ "--gracious-shutdown-seconds",
100
452
  type=int,
101
- default=1,
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
- url: str,
106
- username: str | None,
107
- password: str | None,
108
- exchange: str,
109
- queue: str,
110
- prefetch_count: int,
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
- app = find_microservice_by_module_path(app_path)
114
-
115
- parsed_url = urlparse(url)
116
-
117
- if password is not None:
118
- parsed_url = urlparse(
119
- urlunsplit(
120
- parsed_url._replace(
121
- netloc=f"{parsed_url.username or ''}:{password}@{parsed_url.netloc}"
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
- url = parsed_url.geturl()
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
- def scheduler(
197
- app_path: str,
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
- app = find_microservice_by_module_path(app_path)
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
- Scheduler(app, NullBackend(), SchedulerConfig(interval=interval)).run()
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.File("w"),
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
- help="Watch for file changes and regenerate TypeScript interfaces",
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
- help="Source directory to watch for changes (default: src)",
673
+ envvar="SRC_DIR",
224
674
  )
225
- def gen_tsi(app_path: str, file_path: StreamWriter, watch: bool, src_dir: str) -> None:
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
- # Generate typescript interfaces
229
- def generate_interfaces() -> None:
230
- try:
231
- app = find_microservice_by_module_path(app_path)
232
- content = write_microservice_to_typescript_interface(app)
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
- # Write new content
242
- file_path.write(content)
243
- file_path.flush()
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
- print(f"Generated TypeScript interfaces at {time.strftime('%H:%M:%S')}")
246
- except Exception as e:
247
- print(f"Error generating TypeScript interfaces: {e}", file=sys.stderr)
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
- print(
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
- print(f"File changed: {src_path}")
276
- generate_interfaces()
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
- # Set up observer
279
- observer = Observer()
280
- observer.schedule(PyFileChangeHandler(), src_dir, recursive=True)
281
- observer.start()
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
- print(f"Watching for changes in {os.path.abspath(src_dir)}...")
284
- try:
285
- while True:
286
- time.sleep(1)
287
- except KeyboardInterrupt:
288
- observer.stop()
289
- print("Watch mode stopped")
290
- observer.join()
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()