jararaca 0.3.11a16__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 (88) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +184 -12
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +4 -0
  5. jararaca/broker_backend/mapper.py +4 -0
  6. jararaca/broker_backend/redis_broker_backend.py +9 -3
  7. jararaca/cli.py +272 -47
  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 +41 -7
  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 +4 -0
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +33 -67
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +13 -4
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +4 -0
  23. jararaca/messagebus/message.py +4 -0
  24. jararaca/messagebus/publisher.py +6 -0
  25. jararaca/messagebus/worker.py +850 -383
  26. jararaca/microservice.py +110 -1
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +170 -13
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +4 -0
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +202 -11
  34. jararaca/persistence/base.py +38 -2
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  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 +50 -20
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +88 -86
  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 +97 -45
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +4 -0
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +4 -0
  55. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +16 -10
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +34 -25
  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 +521 -105
  69. jararaca/scheduler/decorators.py +15 -22
  70. jararaca/scheduler/types.py +4 -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 +6 -2
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1074 -173
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +65 -39
  79. jararaca/utils/retry.py +10 -3
  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.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +11 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  87. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  88. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
jararaca/cli.py CHANGED
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import asyncio
2
6
  import importlib
3
7
  import importlib.resources
@@ -5,9 +9,11 @@ import multiprocessing
5
9
  import os
6
10
  import sys
7
11
  import time
12
+ import traceback
13
+ import typing
8
14
  from codecs import StreamWriter
9
15
  from pathlib import Path
10
- from typing import Any
16
+ from typing import TYPE_CHECKING, Any, Callable
11
17
  from urllib.parse import parse_qs, urlparse
12
18
 
13
19
  import aio_pika
@@ -31,6 +37,9 @@ from jararaca.tools.typescript.interface_parser import (
31
37
  )
32
38
  from jararaca.utils.rabbitmq_utils import RabbitmqUtils
33
39
 
40
+ if TYPE_CHECKING:
41
+ from watchdog.observers.api import BaseObserver
42
+
34
43
  LIBRARY_FILES_PATH = importlib.resources.files("jararaca.files")
35
44
  ENTITY_TEMPLATE_PATH = LIBRARY_FILES_PATH / "entity.py.mako"
36
45
 
@@ -256,7 +265,7 @@ async def declare_controller_queues(
256
265
  Declare all message handler and scheduled action queues for controllers.
257
266
  """
258
267
  for instance_type in app.controllers:
259
- controller_spec = MessageBusController.get_messagebus(instance_type)
268
+ controller_spec = MessageBusController.get_last(instance_type)
260
269
  if controller_spec is None:
261
270
  continue
262
271
 
@@ -266,7 +275,7 @@ async def declare_controller_queues(
266
275
  for _, member in members.items():
267
276
  # Check if it's a message handler
268
277
  await declare_message_handler_queue(
269
- connection, member, exchange, force, interactive_mode
278
+ connection, member, exchange, force, interactive_mode, controller_spec
270
279
  )
271
280
 
272
281
  # Check if it's a scheduled action
@@ -281,11 +290,12 @@ async def declare_message_handler_queue(
281
290
  exchange: str,
282
291
  force: bool,
283
292
  interactive_mode: bool,
293
+ controller_spec: MessageBusController,
284
294
  ) -> None:
285
295
  """
286
296
  Declare a queue for a message handler if the member is one.
287
297
  """
288
- message_handler = MessageHandler.get_message_incoming(member.member_function)
298
+ message_handler = MessageHandler.get_last(member.member_function)
289
299
  if message_handler is not None:
290
300
  queue_name = f"{message_handler.message_type.MESSAGE_TOPIC}.{member.member_function.__module__}.{member.member_function.__qualname__}"
291
301
  routing_key = f"{message_handler.message_type.MESSAGE_TOPIC}.#"
@@ -311,7 +321,7 @@ async def declare_scheduled_action_queue(
311
321
  """
312
322
  Declare a queue for a scheduled action if the member is one.
313
323
  """
314
- scheduled_action = ScheduledAction.get_scheduled_action(member.member_function)
324
+ scheduled_action = ScheduledAction.get_last(member.member_function)
315
325
  if scheduled_action is not None:
316
326
  queue_name = (
317
327
  f"{member.member_function.__module__}.{member.member_function.__qualname__}"
@@ -421,25 +431,52 @@ def cli() -> None:
421
431
  @click.option(
422
432
  "--handlers",
423
433
  type=str,
434
+ envvar="HANDLERS",
424
435
  help="Comma-separated list of handler names to listen to. If not specified, all handlers will be used.",
425
436
  )
437
+ @click.option(
438
+ "--reload",
439
+ is_flag=True,
440
+ envvar="RELOAD",
441
+ help="Enable auto-reload when Python files change.",
442
+ )
443
+ @click.option(
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.",
449
+ )
450
+ @click.option(
451
+ "--gracious-shutdown-seconds",
452
+ type=int,
453
+ default=20,
454
+ envvar="GRACIOUS_SHUTDOWN_SECONDS",
455
+ help="Number of seconds to wait for graceful shutdown on reload",
456
+ )
426
457
  def worker(
427
- app_path: str, broker_url: str, backend_url: str, handlers: str | None
458
+ app_path: str,
459
+ broker_url: str,
460
+ backend_url: str,
461
+ handlers: str | None,
462
+ reload: bool,
463
+ src_dir: str,
464
+ gracious_shutdown_seconds: int,
428
465
  ) -> None:
429
466
  """Start a message bus worker that processes asynchronous messages from a message queue."""
430
- app = find_microservice_by_module_path(app_path)
431
467
 
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(
438
- app=app,
439
- broker_url=broker_url,
440
- backend_url=backend_url,
441
- handler_names=handler_names,
442
- ).start_sync()
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
477
+ )
478
+ else:
479
+ run_worker_process(app_path, broker_url, backend_url, handlers)
443
480
 
444
481
 
445
482
  @cli.command()
@@ -485,51 +522,78 @@ def server(app_path: str, host: str, port: int) -> None:
485
522
  @click.argument(
486
523
  "app_path",
487
524
  type=str,
525
+ envvar="APP_PATH",
488
526
  )
489
527
  @click.option(
490
528
  "--interval",
491
529
  type=int,
492
530
  default=1,
493
531
  required=True,
532
+ envvar="INTERVAL",
494
533
  )
495
534
  @click.option(
496
535
  "--broker-url",
497
536
  type=str,
498
537
  required=True,
538
+ envvar="BROKER_URL",
499
539
  )
500
540
  @click.option(
501
541
  "--backend-url",
502
542
  type=str,
503
543
  required=True,
544
+ envvar="BACKEND_URL",
504
545
  )
505
546
  @click.option(
506
547
  "--actions",
507
548
  type=str,
549
+ envvar="ACTIONS",
508
550
  help="Comma-separated list of action names to run (only run actions with these names)",
509
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
+ )
510
572
  def beat(
511
573
  interval: int,
512
574
  broker_url: str,
513
575
  backend_url: str,
514
576
  app_path: str,
515
577
  actions: str | None = None,
578
+ reload: bool = False,
579
+ src_dir: str = "src",
580
+ gracious_shutdown_seconds: int = 20,
516
581
  ) -> None:
517
-
518
- app = find_microservice_by_module_path(app_path)
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(
526
- app=app,
527
- interval=interval,
528
- backend_url=backend_url,
529
- broker_url=broker_url,
530
- scheduled_action_names=scheduler_names,
531
- )
532
- beat_worker.run()
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)
533
597
 
534
598
 
535
599
  def generate_interfaces(
@@ -581,6 +645,7 @@ def generate_interfaces(
581
645
  return content
582
646
  except Exception as e:
583
647
  click.echo(f"Error generating TypeScript interfaces: {e}", file=sys.stderr)
648
+ traceback.print_exc(file=sys.stderr)
584
649
  return ""
585
650
 
586
651
 
@@ -588,29 +653,35 @@ def generate_interfaces(
588
653
  @click.argument(
589
654
  "app_path",
590
655
  type=str,
656
+ envvar="APP_PATH",
591
657
  )
592
658
  @click.argument(
593
659
  "file_path",
594
660
  type=click.Path(file_okay=True, dir_okay=False),
595
661
  required=False,
662
+ envvar="FILE_PATH",
596
663
  )
597
664
  @click.option(
598
665
  "--watch",
599
666
  is_flag=True,
667
+ envvar="WATCH",
600
668
  )
601
669
  @click.option(
602
670
  "--src-dir",
603
671
  type=click.Path(exists=True, file_okay=False, dir_okay=True),
604
672
  default="src",
673
+ envvar="SRC_DIR",
605
674
  )
606
675
  @click.option(
607
676
  "--stdout",
608
677
  is_flag=True,
678
+ envvar="STDOUT",
609
679
  help="Print generated interfaces to stdout instead of writing to a file",
610
680
  )
611
681
  @click.option(
612
682
  "--post-process",
613
683
  type=str,
684
+ envvar="POST_PROCESS",
614
685
  help="Command to run after generating the interfaces, {file} will be replaced with the output file path",
615
686
  )
616
687
  def gen_tsi(
@@ -702,19 +773,23 @@ def gen_tsi(
702
773
 
703
774
  # subprocess.run(cmd, check=False)
704
775
 
705
- # Set up observer
706
- observer = Observer()
707
- observer.schedule(PyFileChangeHandler(), src_dir, recursive=True) # type: ignore
708
- observer.start() # type: ignore
776
+ @typing.no_type_check
777
+ def start_watchdog() -> None:
709
778
 
710
- click.echo(f"Watching for changes in {os.path.abspath(src_dir)}...")
711
- try:
712
- while True:
713
- time.sleep(1)
714
- except KeyboardInterrupt:
715
- observer.stop() # type: ignore
716
- click.echo("Watch mode stopped")
717
- observer.join()
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()
718
793
 
719
794
 
720
795
  def camel_case_to_snake_case(name: str) -> str:
@@ -730,10 +805,11 @@ def camel_case_to_pascal_case(name: str) -> str:
730
805
 
731
806
 
732
807
  @cli.command()
733
- @click.argument("entity_name", type=click.STRING)
808
+ @click.argument("entity_name", type=click.STRING, envvar="ENTITY_NAME")
734
809
  @click.argument(
735
810
  "file_path",
736
811
  type=click.File("w"),
812
+ envvar="FILE_PATH",
737
813
  )
738
814
  def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
739
815
 
@@ -769,6 +845,7 @@ def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
769
845
  "--interactive-mode",
770
846
  is_flag=True,
771
847
  default=False,
848
+ envvar="INTERACTIVE_MODE",
772
849
  help="Enable interactive mode for queue declaration (confirm before deleting existing queues)",
773
850
  )
774
851
  @click.option(
@@ -776,6 +853,7 @@ def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
776
853
  "--force",
777
854
  is_flag=True,
778
855
  default=False,
856
+ envvar="FORCE",
779
857
  help="Force recreation by deleting existing exchanges and queues before declaring them",
780
858
  )
781
859
  def declare(
@@ -835,3 +913,150 @@ def declare(
835
913
  raise
836
914
 
837
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()
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
jararaca/core/__init__.py CHANGED
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from dataclasses import dataclass
2
6
  from typing import Any, Callable, Generic, Type, TypeVar
3
7
 
jararaca/core/uow.py CHANGED
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from contextlib import asynccontextmanager
2
6
  from typing import AsyncGenerator, Sequence
3
7
 
@@ -9,7 +13,7 @@ from jararaca.microservice import (
9
13
  provide_app_context,
10
14
  provide_container,
11
15
  )
12
- from jararaca.reflect.metadata import provide_metadata
16
+ from jararaca.reflect.metadata import start_transaction_metadata_context
13
17
 
14
18
 
15
19
  class ContainerInterceptor(AppInterceptor):
@@ -33,7 +37,6 @@ class UnitOfWorkContextProvider:
33
37
  self.container = container
34
38
  self.container_interceptor = ContainerInterceptor(container)
35
39
 
36
- # TODO: Guarantee that the context is closed whenever an exception is raised
37
40
  # TODO: Guarantee a unit of work workflow for the whole request, including all the interceptors
38
41
 
39
42
  def factory_app_interceptors(self) -> Sequence[AppInterceptor]:
@@ -57,7 +60,9 @@ class UnitOfWorkContextProvider:
57
60
  ) -> AsyncGenerator[None, None]:
58
61
 
59
62
  app_interceptors = self.factory_app_interceptors()
60
- with provide_metadata(app_context.controller_member_reflect.metadata):
63
+ with start_transaction_metadata_context(
64
+ app_context.controller_member_reflect.metadata
65
+ ):
61
66
  ctxs = [self.container_interceptor.intercept(app_context)] + [
62
67
  interceptor.intercept(app_context) for interceptor in app_interceptors
63
68
  ]
@@ -65,7 +70,36 @@ class UnitOfWorkContextProvider:
65
70
  for ctx in ctxs:
66
71
  await ctx.__aenter__()
67
72
 
68
- yield None
69
-
70
- for ctx in reversed(ctxs):
71
- await ctx.__aexit__(None, None, None)
73
+ exc_type = None
74
+ exc_value = None
75
+ exc_traceback = None
76
+
77
+ try:
78
+ yield None
79
+ except BaseException as e:
80
+ exc_type = type(e)
81
+ exc_value = e
82
+ exc_traceback = e.__traceback__
83
+ raise
84
+ finally:
85
+ # Exit interceptors in reverse order, propagating exception info
86
+ for ctx in reversed(ctxs):
87
+ try:
88
+ suppressed = await ctx.__aexit__(
89
+ exc_type, exc_value, exc_traceback
90
+ )
91
+ # If an interceptor returns True, it suppresses the exception
92
+ if suppressed and exc_type is not None:
93
+ exc_type = None
94
+ exc_value = None
95
+ exc_traceback = None
96
+ except BaseException as exit_exc:
97
+ # If an interceptor raises an exception during cleanup,
98
+ # replace the original exception with the new one
99
+ exc_type = type(exit_exc)
100
+ exc_value = exit_exc
101
+ exc_traceback = exit_exc.__traceback__
102
+
103
+ # Re-raise the exception if it wasn't suppressed
104
+ if exc_type is not None and exc_value is not None:
105
+ raise exc_value.with_traceback(exc_traceback)
jararaca/di.py CHANGED
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from jararaca.microservice import Container
2
6
 
3
7
  __all__ = [
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from datetime import datetime
2
6
  from typing import Annotated
3
7
  from uuid import UUID, uuid4
jararaca/lifecycle.py CHANGED
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import logging
2
6
  from contextlib import asynccontextmanager
3
7
  from typing import AsyncContextManager, AsyncGenerator, Sequence
@@ -33,7 +37,7 @@ class AppLifecycle:
33
37
  self.container.fill_providers(False)
34
38
  lifecycle_ctxs: list[AsyncContextManager[None]] = []
35
39
 
36
- logger.info("Initializing interceptors lifecycle")
40
+ logger.debug("Initializing interceptors lifecycle")
37
41
  for interceptor_dep in self.app.interceptors:
38
42
  interceptor: AppInterceptor
39
43
  if not isinstance(interceptor_dep, AppInterceptor):
@@ -57,6 +61,6 @@ class AppLifecycle:
57
61
 
58
62
  yield
59
63
 
60
- logger.info("Finalizing interceptors lifecycle")
64
+ logger.debug("Finalizing interceptors lifecycle")
61
65
  for ctx in lifecycle_ctxs:
62
66
  await ctx.__aexit__(None, None, None)
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from .message import MessageOf
2
6
 
3
7
  __all__ = ["MessageOf"]
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from contextlib import contextmanager, suppress
2
6
  from contextvars import ContextVar
3
7
  from typing import Any, Generator, Protocol
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later