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

README.md ADDED
@@ -0,0 +1,120 @@
1
+ <img src="https://raw.githubusercontent.com/LuscasLeo/jararaca/main/docs/assets/_f04774c9-7e05-4da4-8b17-8be23f6a1475.jpeg" alt="Jararaca Logo" width="250" float="right">
2
+
3
+ # Jararaca Microservice Framework
4
+
5
+ ## Overview
6
+
7
+ Jararaca is an async-first microservice framework designed to simplify the development of distributed systems. It provides a comprehensive set of tools for building robust, scalable, and maintainable microservices with a focus on developer experience and type safety.
8
+
9
+ ## Key Features
10
+
11
+ ### REST API Development
12
+ - Easy-to-use interfaces for building REST APIs
13
+ - Automatic request/response validation
14
+ - Type-safe endpoints with FastAPI integration
15
+ - Automatic OpenAPI documentation generation
16
+
17
+ ### Message Bus Integration
18
+ - Topic-based message bus for event-driven architecture
19
+ - Support for both worker and publisher patterns
20
+ - Built-in message serialization and deserialization
21
+ - Easy integration with AIO Pika for RabbitMQ
22
+
23
+ ### Distributed WebSocket
24
+ - Room-based WebSocket communication
25
+ - Distributed broadcasting across multiple backend instances
26
+ - Automatic message synchronization between instances
27
+ - Built-in connection management and room handling
28
+
29
+ ### Task Scheduling
30
+ - Cron-based task scheduling
31
+ - Support for overlapping and non-overlapping tasks
32
+ - Distributed task execution
33
+ - Easy integration with message bus for task distribution
34
+
35
+ ### TypeScript Integration
36
+ - Automatic TypeScript interface generation
37
+ - Command-line tool for generating TypeScript types
38
+ - Support for REST endpoints, WebSocket events, and message bus payloads
39
+ - Type-safe frontend-backend communication
40
+
41
+ ### Hexagonal Architecture
42
+ - Clear separation of concerns
43
+ - Business logic isolation from infrastructure
44
+ - Easy testing and maintainability
45
+ - Dependency injection for flexible component management
46
+
47
+ ### Observability
48
+ - Built-in OpenTelemetry integration
49
+ - Distributed tracing support
50
+ - Logging and metrics collection
51
+ - Performance monitoring capabilities
52
+
53
+ ## Quick Start
54
+
55
+ ### Installation
56
+
57
+ ```bash
58
+ pip install jararaca
59
+ ```
60
+
61
+ ### Basic Usage
62
+
63
+ ```python
64
+ from jararaca import Microservice, create_http_server
65
+ from jararaca.presentation.http_microservice import HttpMicroservice
66
+
67
+ # Define your microservice
68
+ app = Microservice(
69
+ providers=[
70
+ # Add your providers here
71
+ ],
72
+ controllers=[
73
+ # Add your controllers here
74
+ ],
75
+ interceptors=[
76
+ # Add your interceptors here
77
+ ],
78
+ )
79
+
80
+ # Create HTTP server
81
+ http_app = HttpMicroservice(app)
82
+ web_app = create_http_server(app)
83
+ ```
84
+
85
+ ### Running the Service
86
+
87
+ ```bash
88
+ # Run as HTTP server
89
+ jararaca server app:http_app
90
+
91
+ # Run as message bus worker
92
+ jararaca worker app:app
93
+
94
+ # Run as scheduler
95
+ jararaca scheduler app:app
96
+
97
+ # Generate TypeScript interfaces
98
+ jararaca gen-tsi app.main:app app.ts
99
+ ```
100
+
101
+ ## Documentation
102
+
103
+ For detailed documentation, please visit our [documentation site](https://luscasleo.github.io/jararaca/).
104
+
105
+ ## Examples
106
+
107
+ Check out the [examples directory](examples/) for complete working examples of:
108
+ - REST API implementation
109
+ - WebSocket usage
110
+ - Message bus integration
111
+ - Task scheduling
112
+ - TypeScript interface generation
113
+
114
+ ## Contributing
115
+
116
+ Contributions are welcome! Please read our [contributing guidelines](.github/CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
117
+
118
+ ## License
119
+
120
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
jararaca/__init__.py CHANGED
@@ -45,26 +45,47 @@ if TYPE_CHECKING:
45
45
  )
46
46
  from jararaca.rpc.http.backends.httpx import HTTPXHttpRPCAsyncBackend
47
47
  from jararaca.rpc.http.backends.otel import TracedRequestMiddleware
48
- from jararaca.rpc.http.decorators import Body
48
+ from jararaca.rpc.http.decorators import ( # New request parameter decorators; Configuration decorators; Authentication classes; Middleware classes; Configuration classes; Exception classes
49
+ ApiKeyAuth,
50
+ AuthenticationMiddleware,
51
+ BasicAuth,
52
+ BearerTokenAuth,
53
+ Body,
54
+ CacheMiddleware,
55
+ ContentType,
56
+ )
49
57
  from jararaca.rpc.http.decorators import Delete as HttpDelete
58
+ from jararaca.rpc.http.decorators import ( # New request parameter decorators; Configuration decorators; Authentication classes; Middleware classes; Configuration classes; Exception classes
59
+ File,
60
+ FormData,
61
+ )
50
62
  from jararaca.rpc.http.decorators import Get as HttpGet
51
- from jararaca.rpc.http.decorators import (
63
+ from jararaca.rpc.http.decorators import ( # New request parameter decorators; Configuration decorators; Authentication classes; Middleware classes; Configuration classes; Exception classes
52
64
  GlobalHttpErrorHandler,
53
65
  Header,
54
66
  HttpMapping,
55
67
  HttpRpcClientBuilder,
56
68
  )
57
69
  from jararaca.rpc.http.decorators import Patch as HttpPatch
58
- from jararaca.rpc.http.decorators import PathParam
70
+ from jararaca.rpc.http.decorators import ( # New request parameter decorators; Configuration decorators; Authentication classes; Middleware classes; Configuration classes; Exception classes
71
+ PathParam,
72
+ )
59
73
  from jararaca.rpc.http.decorators import Post as HttpPost
60
74
  from jararaca.rpc.http.decorators import Put as HttpPut
61
- from jararaca.rpc.http.decorators import (
75
+ from jararaca.rpc.http.decorators import ( # New request parameter decorators; Configuration decorators; Authentication classes; Middleware classes; Configuration classes; Exception classes
62
76
  Query,
63
77
  RequestAttribute,
78
+ RequestHook,
79
+ ResponseHook,
80
+ ResponseMiddleware,
64
81
  RestClient,
82
+ Retry,
83
+ RetryConfig,
65
84
  RouteHttpErrorHandler,
66
85
  RPCRequestNetworkError,
67
86
  RPCUnhandleError,
87
+ Timeout,
88
+ TimeoutException,
68
89
  )
69
90
 
70
91
  from .core.providers import ProviderSpec, Token
@@ -81,6 +102,8 @@ if TYPE_CHECKING:
81
102
  from .messagebus.publisher import use_publisher
82
103
  from .microservice import (
83
104
  Microservice,
105
+ is_shutting_down,
106
+ request_shutdown,
84
107
  use_app_context,
85
108
  use_app_transaction_context,
86
109
  use_app_tx_ctx_data,
@@ -96,6 +119,11 @@ if TYPE_CHECKING:
96
119
  use_session,
97
120
  use_transaction,
98
121
  )
122
+ from .persistence.interceptors.decorators import (
123
+ set_use_persistence_session,
124
+ skip_persistence_session,
125
+ uses_persistence_session,
126
+ )
99
127
  from .persistence.utilities import (
100
128
  CriteriaBasedAttributeQueryInjector,
101
129
  CRUDOperations,
@@ -137,6 +165,11 @@ if TYPE_CHECKING:
137
165
  from .presentation.websocket.websocket_interceptor import WebSocketInterceptor
138
166
  from .scheduler.decorators import ScheduledAction
139
167
  from .tools.app_config.interceptor import AppConfigurationInterceptor
168
+ from .tools.typescript.decorators import (
169
+ MutationEndpoint,
170
+ QueryEndpoint,
171
+ SplitInputOutput,
172
+ )
140
173
 
141
174
  __all__ = [
142
175
  "SetMetadata",
@@ -186,6 +219,12 @@ if TYPE_CHECKING:
186
219
  "QueryInjector",
187
220
  "HttpMicroservice",
188
221
  "use_current_container",
222
+ "use_app_context",
223
+ "use_app_transaction_context",
224
+ "use_app_tx_ctx_data",
225
+ "is_shutting_down",
226
+ "request_shutdown",
227
+ "Microservice",
189
228
  "T_BASEMODEL",
190
229
  "DatedEntity",
191
230
  "BaseEntity",
@@ -208,7 +247,6 @@ if TYPE_CHECKING:
208
247
  "MessageBusController",
209
248
  "MessageHandler",
210
249
  "ScheduledAction",
211
- "Microservice",
212
250
  "ProviderSpec",
213
251
  "Token",
214
252
  "AIOSqlAlchemySessionInterceptor",
@@ -220,6 +258,9 @@ if TYPE_CHECKING:
220
258
  "use_transaction",
221
259
  "providing_session",
222
260
  "provide_session",
261
+ "uses_persistence_session",
262
+ "skip_persistence_session",
263
+ "set_use_persistence_session",
223
264
  "providing_transaction",
224
265
  "providing_new_session",
225
266
  "Post",
@@ -233,6 +274,9 @@ if TYPE_CHECKING:
233
274
  "MessageBusPublisherInterceptor",
234
275
  "RedisWebSocketConnectionBackend",
235
276
  "AppConfigurationInterceptor",
277
+ "QueryEndpoint",
278
+ "MutationEndpoint",
279
+ "SplitInputOutput",
236
280
  "UseMiddleware",
237
281
  "UseDependency",
238
282
  "GlobalHttpErrorHandler",
@@ -242,9 +286,27 @@ if TYPE_CHECKING:
242
286
  "provide_ws_manager",
243
287
  "HttpRpcClientBuilder",
244
288
  "HTTPXHttpRPCAsyncBackend",
245
- "use_app_context",
246
- "use_app_transaction_context",
247
- "use_app_tx_ctx_data",
289
+ # New request parameter decorators
290
+ "FormData",
291
+ "File",
292
+ # Configuration decorators
293
+ "Timeout",
294
+ "Retry",
295
+ "ContentType",
296
+ # Authentication classes
297
+ "BearerTokenAuth",
298
+ "BasicAuth",
299
+ "ApiKeyAuth",
300
+ # Middleware classes
301
+ "CacheMiddleware",
302
+ "AuthenticationMiddleware",
303
+ "ResponseMiddleware",
304
+ "RequestHook",
305
+ "ResponseHook",
306
+ # Configuration classes
307
+ "RetryConfig",
308
+ # Exception classes
309
+ "TimeoutException",
248
310
  "AppTransactionContext",
249
311
  "AppContext",
250
312
  "ControllerMemberReflect",
@@ -401,6 +463,21 @@ _dynamic_imports: "dict[str, tuple[str, str, str | None]]" = {
401
463
  "persistence.interceptors.aiosqa_interceptor",
402
464
  None,
403
465
  ),
466
+ "uses_persistence_session": (
467
+ __SPEC_PARENT__,
468
+ "persistence.interceptors.decorators",
469
+ None,
470
+ ),
471
+ "skip_persistence_session": (
472
+ __SPEC_PARENT__,
473
+ "persistence.interceptors.decorators",
474
+ None,
475
+ ),
476
+ "set_use_persistence_session": (
477
+ __SPEC_PARENT__,
478
+ "persistence.interceptors.decorators",
479
+ None,
480
+ ),
404
481
  "Post": (__SPEC_PARENT__, "presentation.decorators", None),
405
482
  "Get": (__SPEC_PARENT__, "presentation.decorators", None),
406
483
  "Patch": (__SPEC_PARENT__, "presentation.decorators", None),
@@ -427,6 +504,9 @@ _dynamic_imports: "dict[str, tuple[str, str, str | None]]" = {
427
504
  "tools.app_config.interceptor",
428
505
  None,
429
506
  ),
507
+ "QueryEndpoint": (__SPEC_PARENT__, "tools.typescript.decorators", None),
508
+ "MutationEndpoint": (__SPEC_PARENT__, "tools.typescript.decorators", None),
509
+ "SplitInputOutput": (__SPEC_PARENT__, "tools.typescript.decorators", None),
430
510
  "UseMiddleware": (__SPEC_PARENT__, "presentation.decorators", None),
431
511
  "UseDependency": (__SPEC_PARENT__, "presentation.decorators", None),
432
512
  "GlobalHttpErrorHandler": (__SPEC_PARENT__, "rpc.http.decorators", None),
@@ -440,9 +520,27 @@ _dynamic_imports: "dict[str, tuple[str, str, str | None]]" = {
440
520
  "provide_ws_manager": (__SPEC_PARENT__, "presentation.websocket.context", None),
441
521
  "HttpRpcClientBuilder": (__SPEC_PARENT__, "rpc.http.decorators", None),
442
522
  "HTTPXHttpRPCAsyncBackend": (__SPEC_PARENT__, "rpc.http.backends.httpx", None),
523
+ # New HTTP RPC classes
524
+ "FormData": (__SPEC_PARENT__, "rpc.http.decorators", None),
525
+ "File": (__SPEC_PARENT__, "rpc.http.decorators", None),
526
+ "Timeout": (__SPEC_PARENT__, "rpc.http.decorators", None),
527
+ "Retry": (__SPEC_PARENT__, "rpc.http.decorators", None),
528
+ "ContentType": (__SPEC_PARENT__, "rpc.http.decorators", None),
529
+ "BearerTokenAuth": (__SPEC_PARENT__, "rpc.http.decorators", None),
530
+ "BasicAuth": (__SPEC_PARENT__, "rpc.http.decorators", None),
531
+ "ApiKeyAuth": (__SPEC_PARENT__, "rpc.http.decorators", None),
532
+ "CacheMiddleware": (__SPEC_PARENT__, "rpc.http.decorators", None),
533
+ "AuthenticationMiddleware": (__SPEC_PARENT__, "rpc.http.decorators", None),
534
+ "ResponseMiddleware": (__SPEC_PARENT__, "rpc.http.decorators", None),
535
+ "RequestHook": (__SPEC_PARENT__, "rpc.http.decorators", None),
536
+ "ResponseHook": (__SPEC_PARENT__, "rpc.http.decorators", None),
537
+ "RetryConfig": (__SPEC_PARENT__, "rpc.http.decorators", None),
538
+ "TimeoutException": (__SPEC_PARENT__, "rpc.http.decorators", None),
443
539
  "use_app_context": (__SPEC_PARENT__, "microservice", None),
444
540
  "use_app_transaction_context": (__SPEC_PARENT__, "microservice", None),
445
541
  "use_app_tx_ctx_data": (__SPEC_PARENT__, "microservice", None),
542
+ "is_shutting_down": (__SPEC_PARENT__, "microservice", None),
543
+ "request_shutdown": (__SPEC_PARENT__, "microservice", None),
446
544
  "AppContext": (__SPEC_PARENT__, "microservice", None),
447
545
  "AppInterceptor": (__SPEC_PARENT__, "microservice", None),
448
546
  "AppTransactionContext": (__SPEC_PARENT__, "microservice", None),
jararaca/cli.py CHANGED
@@ -5,9 +5,10 @@ import multiprocessing
5
5
  import os
6
6
  import sys
7
7
  import time
8
+ import traceback
8
9
  from codecs import StreamWriter
9
10
  from pathlib import Path
10
- from typing import Any
11
+ from typing import Any, Callable
11
12
  from urllib.parse import parse_qs, urlparse
12
13
 
13
14
  import aio_pika
@@ -421,25 +422,42 @@ def cli() -> None:
421
422
  @click.option(
422
423
  "--handlers",
423
424
  type=str,
425
+ envvar="HANDLERS",
424
426
  help="Comma-separated list of handler names to listen to. If not specified, all handlers will be used.",
425
427
  )
428
+ @click.option(
429
+ "--reload",
430
+ is_flag=True,
431
+ envvar="RELOAD",
432
+ help="Enable auto-reload when Python files change.",
433
+ )
434
+ @click.option(
435
+ "--src-dir",
436
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
437
+ default="src",
438
+ envvar="SRC_DIR",
439
+ help="The source directory to watch for changes when --reload is enabled.",
440
+ )
426
441
  def worker(
427
- app_path: str, broker_url: str, backend_url: str, handlers: str | None
442
+ app_path: str,
443
+ broker_url: str,
444
+ backend_url: str,
445
+ handlers: str | None,
446
+ reload: bool,
447
+ src_dir: str,
428
448
  ) -> None:
429
449
  """Start a message bus worker that processes asynchronous messages from a message queue."""
430
- app = find_microservice_by_module_path(app_path)
431
-
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
450
 
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()
451
+ if reload:
452
+ process_args = {
453
+ "app_path": app_path,
454
+ "broker_url": broker_url,
455
+ "backend_url": backend_url,
456
+ "handlers": handlers,
457
+ }
458
+ run_with_reload_watcher(process_args, run_worker_process, src_dir)
459
+ else:
460
+ run_worker_process(app_path, broker_url, backend_url, handlers)
443
461
 
444
462
 
445
463
  @cli.command()
@@ -485,51 +503,68 @@ def server(app_path: str, host: str, port: int) -> None:
485
503
  @click.argument(
486
504
  "app_path",
487
505
  type=str,
506
+ envvar="APP_PATH",
488
507
  )
489
508
  @click.option(
490
509
  "--interval",
491
510
  type=int,
492
511
  default=1,
493
512
  required=True,
513
+ envvar="INTERVAL",
494
514
  )
495
515
  @click.option(
496
516
  "--broker-url",
497
517
  type=str,
498
518
  required=True,
519
+ envvar="BROKER_URL",
499
520
  )
500
521
  @click.option(
501
522
  "--backend-url",
502
523
  type=str,
503
524
  required=True,
525
+ envvar="BACKEND_URL",
504
526
  )
505
527
  @click.option(
506
528
  "--actions",
507
529
  type=str,
530
+ envvar="ACTIONS",
508
531
  help="Comma-separated list of action names to run (only run actions with these names)",
509
532
  )
533
+ @click.option(
534
+ "--reload",
535
+ is_flag=True,
536
+ envvar="RELOAD",
537
+ help="Enable auto-reload when Python files change.",
538
+ )
539
+ @click.option(
540
+ "--src-dir",
541
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
542
+ default="src",
543
+ envvar="SRC_DIR",
544
+ help="The source directory to watch for changes when --reload is enabled.",
545
+ )
510
546
  def beat(
511
547
  interval: int,
512
548
  broker_url: str,
513
549
  backend_url: str,
514
550
  app_path: str,
515
551
  actions: str | None = None,
552
+ reload: bool = False,
553
+ src_dir: str = "src",
516
554
  ) -> 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()
555
+ """Start a scheduler that dispatches scheduled actions to workers."""
556
+
557
+ if reload:
558
+ process_args = {
559
+ "app_path": app_path,
560
+ "interval": interval,
561
+ "broker_url": broker_url,
562
+ "backend_url": backend_url,
563
+ "actions": actions,
564
+ }
565
+ run_with_reload_watcher(process_args, run_beat_process, src_dir)
566
+ else:
567
+ run_beat_process(app_path, interval, broker_url, backend_url, actions)
533
568
 
534
569
 
535
570
  def generate_interfaces(
@@ -581,6 +616,7 @@ def generate_interfaces(
581
616
  return content
582
617
  except Exception as e:
583
618
  click.echo(f"Error generating TypeScript interfaces: {e}", file=sys.stderr)
619
+ traceback.print_exc(file=sys.stderr)
584
620
  return ""
585
621
 
586
622
 
@@ -588,29 +624,35 @@ def generate_interfaces(
588
624
  @click.argument(
589
625
  "app_path",
590
626
  type=str,
627
+ envvar="APP_PATH",
591
628
  )
592
629
  @click.argument(
593
630
  "file_path",
594
631
  type=click.Path(file_okay=True, dir_okay=False),
595
632
  required=False,
633
+ envvar="FILE_PATH",
596
634
  )
597
635
  @click.option(
598
636
  "--watch",
599
637
  is_flag=True,
638
+ envvar="WATCH",
600
639
  )
601
640
  @click.option(
602
641
  "--src-dir",
603
642
  type=click.Path(exists=True, file_okay=False, dir_okay=True),
604
643
  default="src",
644
+ envvar="SRC_DIR",
605
645
  )
606
646
  @click.option(
607
647
  "--stdout",
608
648
  is_flag=True,
649
+ envvar="STDOUT",
609
650
  help="Print generated interfaces to stdout instead of writing to a file",
610
651
  )
611
652
  @click.option(
612
653
  "--post-process",
613
654
  type=str,
655
+ envvar="POST_PROCESS",
614
656
  help="Command to run after generating the interfaces, {file} will be replaced with the output file path",
615
657
  )
616
658
  def gen_tsi(
@@ -730,10 +772,11 @@ def camel_case_to_pascal_case(name: str) -> str:
730
772
 
731
773
 
732
774
  @cli.command()
733
- @click.argument("entity_name", type=click.STRING)
775
+ @click.argument("entity_name", type=click.STRING, envvar="ENTITY_NAME")
734
776
  @click.argument(
735
777
  "file_path",
736
778
  type=click.File("w"),
779
+ envvar="FILE_PATH",
737
780
  )
738
781
  def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
739
782
 
@@ -769,6 +812,7 @@ def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
769
812
  "--interactive-mode",
770
813
  is_flag=True,
771
814
  default=False,
815
+ envvar="INTERACTIVE_MODE",
772
816
  help="Enable interactive mode for queue declaration (confirm before deleting existing queues)",
773
817
  )
774
818
  @click.option(
@@ -776,6 +820,7 @@ def gen_entity(entity_name: str, file_path: StreamWriter) -> None:
776
820
  "--force",
777
821
  is_flag=True,
778
822
  default=False,
823
+ envvar="FORCE",
779
824
  help="Force recreation by deleting existing exchanges and queues before declaring them",
780
825
  )
781
826
  def declare(
@@ -835,3 +880,143 @@ def declare(
835
880
  raise
836
881
 
837
882
  asyncio.run(run_declarations())
883
+
884
+
885
+ def run_worker_process(
886
+ app_path: str, broker_url: str, backend_url: str, handlers: str | None
887
+ ) -> None:
888
+ """Run a worker process with the given parameters."""
889
+ app = find_microservice_by_module_path(app_path)
890
+
891
+ # Parse handler names if provided
892
+ handler_names: set[str] | None = None
893
+ if handlers:
894
+ handler_names = {name.strip() for name in handlers.split(",") if name.strip()}
895
+
896
+ click.echo(f"Starting worker for {app_path}...")
897
+ worker_mod.MessageBusWorker(
898
+ app=app,
899
+ broker_url=broker_url,
900
+ backend_url=backend_url,
901
+ handler_names=handler_names,
902
+ ).start_sync()
903
+
904
+
905
+ def run_beat_process(
906
+ app_path: str, interval: int, broker_url: str, backend_url: str, actions: str | None
907
+ ) -> None:
908
+ """Run a beat scheduler process with the given parameters."""
909
+ app = find_microservice_by_module_path(app_path)
910
+
911
+ # Parse scheduler names if provided
912
+ scheduler_names: set[str] | None = None
913
+ if actions:
914
+ scheduler_names = {name.strip() for name in actions.split(",") if name.strip()}
915
+
916
+ click.echo(f"Starting beat scheduler for {app_path}...")
917
+ beat_worker = BeatWorker(
918
+ app=app,
919
+ interval=interval,
920
+ backend_url=backend_url,
921
+ broker_url=broker_url,
922
+ scheduled_action_names=scheduler_names,
923
+ )
924
+ beat_worker.run()
925
+
926
+
927
+ def run_with_reload_watcher(
928
+ process_args: dict[str, Any],
929
+ process_target: Callable[..., Any],
930
+ src_dir: str = "src",
931
+ ) -> None:
932
+ """
933
+ Run a process with a file watcher that will restart it when Python files change.
934
+
935
+ Args:
936
+ process_args: Arguments to pass to the process function
937
+ process_target: The function to run as the process
938
+ src_dir: The directory to watch for changes
939
+ """
940
+ try:
941
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
942
+ from watchdog.observers import Observer
943
+ except ImportError:
944
+ click.echo(
945
+ "Watchdog is required for reload mode. Install it with: pip install watchdog",
946
+ file=sys.stderr,
947
+ )
948
+ return
949
+
950
+ # Run the initial process
951
+ process = multiprocessing.get_context("spawn").Process(
952
+ target=process_target,
953
+ kwargs=process_args,
954
+ daemon=False, # Non-daemon to ensure it completes properly
955
+ )
956
+ process.start() # Set up file system event handler
957
+
958
+ class PyFileChangeHandler(FileSystemEventHandler):
959
+ def __init__(self) -> None:
960
+ self.last_modified_time = time.time()
961
+ self.debounce_seconds = 1.0 # Debounce to avoid multiple restarts
962
+ self.active_process = process
963
+
964
+ def on_modified(self, event: FileSystemEvent) -> None:
965
+ src_path = (
966
+ event.src_path
967
+ if isinstance(event.src_path, str)
968
+ else str(event.src_path)
969
+ )
970
+
971
+ # Ignore non-Python files and directories
972
+ if event.is_directory or not src_path.endswith(".py"):
973
+ return
974
+
975
+ # Debounce to avoid multiple restarts
976
+ current_time = time.time()
977
+ if current_time - self.last_modified_time < self.debounce_seconds:
978
+ return
979
+ self.last_modified_time = current_time
980
+
981
+ click.echo(f"Detected change in {src_path}")
982
+ click.echo("Restarting process...")
983
+
984
+ # Terminate the current process
985
+ if self.active_process and self.active_process.is_alive():
986
+ self.active_process.terminate()
987
+ self.active_process.join(timeout=5)
988
+
989
+ # If process doesn't terminate, kill it
990
+ if self.active_process.is_alive():
991
+ click.echo("Process did not terminate gracefully, killing it")
992
+ self.active_process.kill()
993
+ self.active_process.join()
994
+
995
+ # Create a new process
996
+ self.active_process = multiprocessing.get_context("spawn").Process(
997
+ target=process_target,
998
+ kwargs=process_args,
999
+ daemon=False,
1000
+ )
1001
+ self.active_process.start()
1002
+
1003
+ # Set up observer
1004
+ observer = Observer()
1005
+ observer.schedule(PyFileChangeHandler(), src_dir, recursive=True) # type: ignore[no-untyped-call]
1006
+ observer.start() # type: ignore[no-untyped-call]
1007
+
1008
+ click.echo(f"Watching for changes in {os.path.abspath(src_dir)}...")
1009
+ try:
1010
+ while True:
1011
+ time.sleep(1)
1012
+ except KeyboardInterrupt:
1013
+ observer.stop() # type: ignore[no-untyped-call]
1014
+ if process.is_alive():
1015
+ click.echo("Stopping process...")
1016
+ process.terminate()
1017
+ process.join(timeout=5)
1018
+ if process.is_alive():
1019
+ process.kill()
1020
+ process.join()
1021
+ click.echo("Reload mode stopped")
1022
+ observer.join()