orionis 0.521.0__py3-none-any.whl → 0.523.0__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.
@@ -30,7 +30,9 @@ from orionis.console.entities.job_executed import JobExecuted
30
30
  from orionis.console.entities.job_max_instances import JobMaxInstances
31
31
  from orionis.console.entities.job_missed import JobMissed
32
32
  from orionis.console.entities.job_modified import JobModified
33
+ from orionis.console.entities.job_pause import JobPause
33
34
  from orionis.console.entities.job_removed import JobRemoved
35
+ from orionis.console.entities.job_resume import JobResume
34
36
  from orionis.console.entities.job_submitted import JobSubmitted
35
37
  from orionis.console.entities.scheduler_paused import SchedulerPaused
36
38
  from orionis.console.entities.scheduler_resumed import SchedulerResumed
@@ -40,7 +42,6 @@ from orionis.console.enums.listener import ListeningEvent
40
42
  from orionis.console.enums.event import Event as EventEntity
41
43
  from orionis.console.exceptions import CLIOrionisRuntimeError
42
44
  from orionis.console.exceptions.cli_orionis_value_error import CLIOrionisValueError
43
- from orionis.console.output.contracts.console import IConsole
44
45
  from orionis.foundation.contracts.application import IApplication
45
46
  from orionis.services.log.contracts.log_service import ILogger
46
47
 
@@ -50,7 +51,6 @@ class Scheduler(ISchedule):
50
51
  self,
51
52
  reactor: IReactor,
52
53
  app: IApplication,
53
- console: IConsole,
54
54
  rich_console: Console
55
55
  ) -> None:
56
56
  """
@@ -74,10 +74,7 @@ class Scheduler(ISchedule):
74
74
  """
75
75
 
76
76
  # Store the application instance for configuration access.
77
- self.__app = app
78
-
79
- # Store the console instance for output operations.
80
- self.__console = console
77
+ self.__app: IApplication = app
81
78
 
82
79
  # Store the rich console instance for advanced output formatting.
83
80
  self.__rich_console = rich_console
@@ -99,7 +96,7 @@ class Scheduler(ISchedule):
99
96
  self.__logger: ILogger = self.__app.make('x-orionis.services.log.log_service')
100
97
 
101
98
  # Store the reactor instance for command management.
102
- self.__reactor = reactor
99
+ self.__reactor: IReactor = reactor
103
100
 
104
101
  # Retrieve and store all available commands from the reactor.
105
102
  self.__available_commands = self.__getCommands()
@@ -113,10 +110,12 @@ class Scheduler(ISchedule):
113
110
  # Initialize the listeners dictionary to manage event listeners.
114
111
  self.__listeners: Dict[str, callable] = {}
115
112
 
113
+ # Initialize set to track jobs paused by pauseEverythingAt
114
+ self.__paused_by_pause_everything: set = set()
115
+
116
116
  # Add this line to the existing __init__ method
117
117
  self._stop_event: Optional[asyncio.Event] = None
118
118
 
119
-
120
119
  def __getCurrentTime(
121
120
  self
122
121
  ) -> str:
@@ -137,6 +136,9 @@ class Scheduler(ISchedule):
137
136
  tz = pytz.timezone(self.__app.config("app.timezone", "UTC"))
138
137
  now = datetime.now(tz)
139
138
 
139
+ # Log the timezone assignment for debugging purposes
140
+ self.__logger.info(f"Timezone assigned to the scheduler: {self.__app.config("app.timezone", "UTC")}")
141
+
140
142
  # Format the current time as a string
141
143
  return now.strftime("%Y-%m-%d %H:%M:%S")
142
144
 
@@ -267,7 +269,7 @@ class Scheduler(ISchedule):
267
269
  """
268
270
 
269
271
  # Prevent adding new commands while the scheduler is running
270
- if self.__scheduler.running:
272
+ if self.isRunning():
271
273
  raise CLIOrionisRuntimeError("Cannot add new commands while the scheduler is running.")
272
274
 
273
275
  # Validate that the command signature is a non-empty string
@@ -282,8 +284,10 @@ class Scheduler(ISchedule):
282
284
  if not self.__isAvailable(signature):
283
285
  raise CLIOrionisValueError(f"The command '{signature}' is not available or does not exist.")
284
286
 
285
- # Store the command and its arguments for scheduling
287
+ # Import Event here to avoid circular dependency issues
286
288
  from orionis.console.tasks.event import Event
289
+
290
+ # Store the command and its arguments for scheduling
287
291
  self.__events[signature] = Event(
288
292
  signature=signature,
289
293
  args=args or [],
@@ -315,8 +319,6 @@ class Scheduler(ISchedule):
315
319
  """
316
320
 
317
321
  self.__scheduler.add_listener(self.__startedListener, EVENT_SCHEDULER_STARTED)
318
- self.__scheduler.add_listener(self.__pausedListener, EVENT_SCHEDULER_PAUSED)
319
- self.__scheduler.add_listener(self.__resumedListener, EVENT_SCHEDULER_RESUMED)
320
322
  self.__scheduler.add_listener(self.__shutdownListener, EVENT_SCHEDULER_SHUTDOWN)
321
323
  self.__scheduler.add_listener(self.__errorListener, EVENT_JOB_ERROR)
322
324
  self.__scheduler.add_listener(self.__submittedListener, EVENT_JOB_SUBMITTED)
@@ -367,6 +369,8 @@ class Scheduler(ISchedule):
367
369
 
368
370
  # Check if a listener is registered for the specified event
369
371
  if scheduler_event in self.__listeners:
372
+
373
+ # Retrieve the listener for the specified event
370
374
  listener = self.__listeners[scheduler_event]
371
375
 
372
376
  # Ensure the listener is callable before invoking it
@@ -376,26 +380,55 @@ class Scheduler(ISchedule):
376
380
 
377
381
  # If the listener is a coroutine, schedule it as an asyncio task
378
382
  if asyncio.iscoroutinefunction(listener):
383
+
384
+ # Try to get the running event loop
379
385
  try:
380
- # Try to get the running event loop
381
- loop = asyncio.get_running_loop()
382
- loop.create_task(listener(event_data, self))
386
+
387
+ try:
388
+
389
+ # Try to get the current running event loop
390
+ loop = asyncio.get_running_loop()
391
+ loop.create_task(listener(event_data, self))
392
+
393
+ except RuntimeError:
394
+
395
+ # If no event loop is running, create a new one
396
+ loop = asyncio.new_event_loop()
397
+ asyncio.set_event_loop(loop)
398
+ loop.create_task(listener(event_data, self))
399
+
400
+ # If no event loop is running, log a warning instead of creating one
383
401
  except RuntimeError:
384
- # If no event loop is running, create a new one
385
- asyncio.run(listener(event_data, self))
402
+
403
+ # Message indicating that the listener could not be invoked
404
+ error_msg = f"Cannot run async listener for '{scheduler_event}': no event loop running"
405
+
406
+ # Log the error message
407
+ self.__logger.error(error_msg)
408
+
409
+ # Raise an error to inform the caller that the listener could not be invoked
410
+ raise CLIOrionisRuntimeError(error_msg)
411
+
386
412
  # Otherwise, invoke the listener directly as a regular function
387
413
  else:
414
+
415
+ # Call the regular listener function directly
388
416
  listener(event_data, self)
389
417
 
390
418
  except Exception as e:
391
419
 
392
- # Log any exceptions that occur during listener invocation
393
- self.__logger.error(f"Error invoking global listener for event '{scheduler_event}': {str(e)}")
420
+ # Re-raise CLIOrionisRuntimeError exceptions
421
+ if isinstance(e, CLIOrionisRuntimeError):
422
+ raise e
423
+
424
+ # Construct the error message
425
+ error_msg = f"An error occurred while invoking the listener for event '{scheduler_event}': {str(e)}"
426
+
427
+ # Log the error message
428
+ self.__logger.error(error_msg)
394
429
 
395
430
  # Raise a runtime error if listener invocation fails
396
- raise CLIOrionisRuntimeError(
397
- f"An error occurred while invoking the listener for event '{scheduler_event}': {str(e)}"
398
- )
431
+ raise CLIOrionisRuntimeError(error_msg)
399
432
 
400
433
  def __taskCallableListener(
401
434
  self,
@@ -453,19 +486,64 @@ class Scheduler(ISchedule):
453
486
 
454
487
  # Check if the listener has a method corresponding to the event type
455
488
  if hasattr(listener, scheduler_event) and callable(getattr(listener, scheduler_event)):
489
+
490
+ # Retrieve the method from the listener
456
491
  listener_method = getattr(listener, scheduler_event)
457
492
 
493
+ # Try to invoke the listener method
458
494
  try:
495
+
459
496
  # Invoke the listener method, handling both coroutine and regular functions
460
497
  if asyncio.iscoroutinefunction(listener_method):
461
- # Schedule the coroutine listener method as an asyncio task
462
- asyncio.create_task(listener_method(event_data, self))
498
+
499
+ # Try to get the running event loop
500
+ try:
501
+
502
+ try:
503
+
504
+ # Try to get the current running event loop
505
+ loop = asyncio.get_running_loop()
506
+ loop.create_task(listener_method(event_data, self))
507
+
508
+ except RuntimeError:
509
+
510
+ # If no event loop is running, create a new one
511
+ loop = asyncio.new_event_loop()
512
+ asyncio.set_event_loop(loop)
513
+ loop.create_task(listener_method(event_data, self))
514
+
515
+ # If no event loop is running, log a warning
516
+ except RuntimeError:
517
+
518
+ # Construct the error message
519
+ error_msg = f"Cannot run async listener for '{scheduler_event}' on job '{event_data.job_id}': no event loop running"
520
+
521
+ # Log the error message
522
+ self.__logger.error(error_msg)
523
+
524
+ # Raise an error to inform the caller that the listener could not be invoked
525
+ raise CLIOrionisRuntimeError(error_msg)
526
+
527
+ # Call the regular listener method directly
463
528
  else:
464
- # Call the regular listener method directly
529
+
530
+ # Invoke the listener method directly
465
531
  listener_method(event_data, self)
532
+
466
533
  except Exception as e:
467
- # Log any exceptions that occur during listener invocation
468
- self.__logger.error(f"Error invoking listener method '{scheduler_event}' for job '{event_data.job_id}': {str(e)}")
534
+
535
+ # Re-raise CLIOrionisRuntimeError exceptions
536
+ if isinstance(e, CLIOrionisRuntimeError):
537
+ raise e
538
+
539
+ # Construct the error message
540
+ error_msg = (f"An error occurred while invoking the listener for event '{scheduler_event}' on job '{event_data.job_id}': {str(e)}")
541
+
542
+ # Log the error message
543
+ self.__logger.error(error_msg)
544
+
545
+ # Raise a runtime error if listener invocation fails
546
+ raise CLIOrionisRuntimeError(error_msg)
469
547
 
470
548
  def __startedListener(
471
549
  self,
@@ -495,9 +573,6 @@ class Scheduler(ISchedule):
495
573
  # Get the current time in the configured timezone
496
574
  now = self.__getCurrentTime()
497
575
 
498
- # Log an informational message indicating that the scheduler has started
499
- self.__logger.info(f"Orionis Scheduler started successfully at {now}.")
500
-
501
576
  # Display a start message for the scheduler worker on the rich console
502
577
  # Add a blank line for better formatting
503
578
  self.__rich_console.line()
@@ -522,6 +597,9 @@ class Scheduler(ISchedule):
522
597
  # Check if a listener is registered for the scheduler started event
523
598
  self.__globalCallableListener(event, ListeningEvent.SCHEDULER_STARTED)
524
599
 
600
+ # Log an informational message indicating that the scheduler has started
601
+ self.__logger.info(f"Orionis Scheduler started successfully at {now}.")
602
+
525
603
  def __pausedListener(
526
604
  self,
527
605
  event: SchedulerPaused
@@ -550,15 +628,12 @@ class Scheduler(ISchedule):
550
628
  # Get the current time in the configured timezone
551
629
  now = self.__getCurrentTime()
552
630
 
553
- # Create a paused message
554
- message = f"Orionis Scheduler paused successfully at {now}."
555
-
556
- # Log an informational message indicating that the scheduler has been paused
557
- self.__logger.info(message)
558
-
559
631
  # Check if a listener is registered for the scheduler paused event
560
632
  self.__globalCallableListener(event, ListeningEvent.SCHEDULER_PAUSED)
561
633
 
634
+ # Log an informational message indicating that the scheduler has been paused
635
+ self.__logger.info(f"Orionis Scheduler paused successfully at {now}.")
636
+
562
637
  def __resumedListener(
563
638
  self,
564
639
  event: SchedulerResumed
@@ -587,15 +662,12 @@ class Scheduler(ISchedule):
587
662
  # Get the current time in the configured timezone
588
663
  now = self.__getCurrentTime()
589
664
 
590
- # Create a resumed message
591
- message = f"Orionis Scheduler resumed successfully at {now}."
592
-
593
- # Log an informational message indicating that the scheduler has resumed
594
- self.__logger.info(message)
595
-
596
665
  # Check if a listener is registered for the scheduler resumed event
597
666
  self.__globalCallableListener(event, ListeningEvent.SCHEDULER_RESUMED)
598
667
 
668
+ # Log an informational message indicating that the scheduler has resumed
669
+ self.__logger.info(f"Orionis Scheduler resumed successfully at {now}.")
670
+
599
671
  def __shutdownListener(
600
672
  self,
601
673
  event: SchedulerShutdown
@@ -624,15 +696,12 @@ class Scheduler(ISchedule):
624
696
  # Get the current time in the configured timezone
625
697
  now = self.__getCurrentTime()
626
698
 
627
- # Create a shutdown message
628
- message = f"Orionis Scheduler shut down successfully at {now}."
629
-
630
- # Log an informational message indicating that the scheduler has shut down
631
- self.__logger.info(message)
632
-
633
699
  # Check if a listener is registered for the scheduler shutdown event
634
700
  self.__globalCallableListener(event, ListeningEvent.SCHEDULER_SHUTDOWN)
635
701
 
702
+ # Log an informational message indicating that the scheduler has shut down
703
+ self.__logger.info(f"Orionis Scheduler shut down successfully at {now}.")
704
+
636
705
  def __errorListener(
637
706
  self,
638
707
  event: JobError
@@ -658,11 +727,8 @@ class Scheduler(ISchedule):
658
727
  and listener invocation for the job error event.
659
728
  """
660
729
 
661
- # Create an error message
662
- message = f"Task {event.job_id} raised an exception: {event.exception}"
663
-
664
730
  # Log an error message indicating that the job raised an exception
665
- self.__logger.error(message)
731
+ self.__logger.error(f"Task {event.job_id} raised an exception: {event.exception}")
666
732
 
667
733
  # If a listener is registered for this job ID, invoke the listener with the event details
668
734
  self.__taskCallableListener(event, ListeningEvent.JOB_ON_FAILURE)
@@ -695,11 +761,8 @@ class Scheduler(ISchedule):
695
761
  and listener invocation for the job submission event.
696
762
  """
697
763
 
698
- # Create a submission message
699
- message = f"Task {event.job_id} submitted to executor."
700
-
701
764
  # Log an informational message indicating that the job has been submitted
702
- self.__logger.info(message)
765
+ self.__logger.info(f"Task {event.job_id} submitted to executor.")
703
766
 
704
767
  # If a listener is registered for this job ID, invoke the listener with the event details
705
768
  self.__taskCallableListener(event, ListeningEvent.JOB_BEFORE)
@@ -730,11 +793,8 @@ class Scheduler(ISchedule):
730
793
  and listener invocation for the job execution event.
731
794
  """
732
795
 
733
- # Create an execution message
734
- message = f"Task {event.job_id} executed."
735
-
736
796
  # Log an informational message indicating that the job has been executed
737
- self.__logger.info(message)
797
+ self.__logger.info(f"Task {event.job_id} executed.")
738
798
 
739
799
  # If a listener is registered for this job ID, invoke the listener with the event details
740
800
  self.__taskCallableListener(event, ListeningEvent.JOB_AFTER)
@@ -765,11 +825,8 @@ class Scheduler(ISchedule):
765
825
  and listener invocation for the missed job event.
766
826
  """
767
827
 
768
- # Create a missed job message
769
- message = f"Task {event.job_id} was missed. It was scheduled to run at {event.scheduled_run_time}."
770
-
771
828
  # Log a warning indicating that the job was missed
772
- self.__logger.warning(message)
829
+ self.__logger.warning(f"Task {event.job_id} was missed. It was scheduled to run at {event.scheduled_run_time}.")
773
830
 
774
831
  # If a listener is registered for this job ID, invoke the listener with the event details
775
832
  self.__taskCallableListener(event, ListeningEvent.JOB_ON_MISSED)
@@ -800,11 +857,8 @@ class Scheduler(ISchedule):
800
857
  and listener invocation for the job max instances event.
801
858
  """
802
859
 
803
- # Create a max instances error message
804
- message = f"Task {event.job_id} exceeded maximum instances"
805
-
806
860
  # Log an error message indicating that the job exceeded maximum instances
807
- self.__logger.error(message)
861
+ self.__logger.error(f"Task {event.job_id} exceeded maximum instances")
808
862
 
809
863
  # If a listener is registered for this job ID, invoke the listener with the event details
810
864
  self.__taskCallableListener(event, ListeningEvent.JOB_ON_MAXINSTANCES)
@@ -835,16 +889,12 @@ class Scheduler(ISchedule):
835
889
  and listener invocation for the job modified event.
836
890
  """
837
891
 
838
- # Create a modified message
839
- message = f"Task {event.job_id} has been modified."
840
-
841
- # Log an informational message indicating that the job has been modified
842
- self.__logger.info(message)
843
-
844
892
  # If a listener is registered for this job ID, invoke the listener with the event details
845
893
  if event.next_run_time is None:
894
+ self.__logger.info(f"Task {event.job_id} has been paused.")
846
895
  self.__taskCallableListener(event, ListeningEvent.JOB_ON_PAUSED)
847
896
  else:
897
+ self.__logger.info(f"Task {event.job_id} has been resumed.")
848
898
  self.__taskCallableListener(event, ListeningEvent.JOB_ON_RESUMED)
849
899
 
850
900
  def __removedListener(
@@ -872,11 +922,8 @@ class Scheduler(ISchedule):
872
922
  listener for the job removal event.
873
923
  """
874
924
 
875
- # Create a message indicating that the job has been removed
876
- message = f"Task {event.job_id} has been removed."
877
-
878
925
  # Log the removal of the job
879
- self.__logger.info(message)
926
+ self.__logger.info(f"Task {event.job_id} has been removed.")
880
927
 
881
928
  # If a listener is registered for this job ID, invoke the listener with the event details
882
929
  self.__taskCallableListener(event, ListeningEvent.JOB_ON_REMOVED)
@@ -903,27 +950,43 @@ class Scheduler(ISchedule):
903
950
  # Iterate through all scheduled jobs in the AsyncIOScheduler
904
951
  for signature, event in self.__events.items():
905
952
 
906
- # Convert the event to its entity representation
907
- entity = event.toEntity()
953
+ try:
954
+ # Convert the event to its entity representation
955
+ entity = event.toEntity()
956
+
957
+ # Add the job to the internal jobs list
958
+ self.__jobs.append(entity)
959
+
960
+ # Create a unique key for the job based on its signature
961
+ def create_job_func(cmd, args_list):
962
+ return lambda: self.__reactor.call(cmd, args_list)
963
+
964
+ # Add the job to the scheduler with the specified trigger and parameters
965
+ self.__scheduler.add_job(
966
+ func=create_job_func(signature, list(entity.args)),
967
+ trigger=entity.trigger,
968
+ id=signature,
969
+ name=signature,
970
+ replace_existing=True
971
+ )
908
972
 
909
- # Add the job to the internal jobs list
910
- self.__jobs.append(entity)
973
+ # If a listener is associated with the event, register it
974
+ if entity.listener:
975
+ self.setListener(signature, entity.listener)
911
976
 
912
- # Create a unique key for the job based on its signature
913
- def create_job_func(cmd, args_list):
914
- return lambda: self.__reactor.call(cmd, args_list)
977
+ # Log the successful loading of the scheduled event
978
+ self.__logger.debug(f"Scheduled event '{signature}' loaded successfully.")
915
979
 
916
- self.__scheduler.add_job(
917
- func=create_job_func(signature, list(entity.args)),
918
- trigger=entity.trigger,
919
- id=signature,
920
- name=signature,
921
- replace_existing=True
922
- )
980
+ except Exception as e:
923
981
 
924
- # If a listener is associated with the event, register it
925
- if entity.listener:
926
- self.setListener(signature, entity.listener)
982
+ # Construct the error message
983
+ error_msg = f"Failed to load scheduled event '{signature}': {str(e)}"
984
+
985
+ # Log the error message
986
+ self.__logger.error(error_msg)
987
+
988
+ # Raise a runtime error if loading the scheduled event fails
989
+ raise CLIOrionisRuntimeError(error_msg)
927
990
 
928
991
  def setListener(
929
992
  self,
@@ -1001,7 +1064,7 @@ class Scheduler(ISchedule):
1001
1064
  Raises
1002
1065
  ------
1003
1066
  ValueError
1004
- If the 'at' parameter is not a valid datetime object.
1067
+ If the 'at' parameter is not a valid datetime object or is not in the future.
1005
1068
  CLIOrionisRuntimeError
1006
1069
  If the scheduler is not running or if an error occurs during job scheduling.
1007
1070
  """
@@ -1010,27 +1073,78 @@ class Scheduler(ISchedule):
1010
1073
  if not isinstance(at, datetime):
1011
1074
  raise ValueError("The 'at' parameter must be a datetime object.")
1012
1075
 
1013
- # Ensure the scheduler is running before scheduling a pause operation
1014
- if not self.__scheduler.running:
1015
- raise CLIOrionisRuntimeError("Cannot schedule pause operation: scheduler is not running.")
1076
+ # Define a function to pause the scheduler
1077
+ def schedule_pause():
1078
+
1079
+ # Only pause jobs if the scheduler is currently running
1080
+ if self.isRunning():
1081
+
1082
+ # Clear the set of previously paused jobs
1083
+ self.__paused_by_pause_everything.clear()
1084
+
1085
+ # Get all jobs from the scheduler
1086
+ all_jobs = self.__scheduler.get_jobs()
1087
+
1088
+ # Filter out system jobs (pause, resume, shutdown tasks)
1089
+ system_job_ids = {
1090
+ "scheduler_pause_at",
1091
+ "scheduler_resume_at",
1092
+ "scheduler_shutdown_at"
1093
+ }
1094
+
1095
+ # Pause only user jobs, not system jobs
1096
+ for job in all_jobs:
1097
+
1098
+ # Pause the job only if it is not a system job
1099
+ if job.id not in system_job_ids:
1100
+
1101
+ # Pause the job and add its ID to the set of paused jobs
1102
+ try:
1103
+
1104
+ # Pause the job in the scheduler
1105
+ self.__scheduler.pause_job(job.id)
1106
+ self.__paused_by_pause_everything.add(job.id)
1107
+
1108
+ # Excute the paused listener
1109
+ self.__pausedListener(JobPause(
1110
+ code=EVENT_SCHEDULER_PAUSED,
1111
+ job_id=job.id if hasattr(job, 'id') else None,
1112
+ alias=job.name if hasattr(job, 'name') else None,
1113
+ jobstore=job.jobstore if hasattr(job, 'jobstore') else None,
1114
+ ))
1115
+
1116
+ except Exception as e:
1117
+
1118
+ # Log a warning if pausing a job fails, but continue with others
1119
+ self.__logger.warning(f"Failed to pause job '{job.id}': {str(e)}")
1120
+
1121
+ # Log that all user jobs have been paused
1122
+ self.__logger.info("All user jobs have been paused. System jobs remain active.")
1016
1123
 
1017
1124
  try:
1125
+
1018
1126
  # Remove any existing pause job to avoid conflicts
1019
1127
  try:
1020
- self.__scheduler.remove_job(ListeningEvent.SCHEDULER_PAUSED.value)
1128
+ self.__scheduler.remove_job("scheduler_pause_at")
1129
+
1130
+ # If the job doesn't exist, it's fine to proceed
1021
1131
  except:
1022
- pass # If the job doesn't exist, it's fine to proceed
1132
+ pass
1023
1133
 
1024
1134
  # Add a job to the scheduler to pause it at the specified datetime
1025
1135
  self.__scheduler.add_job(
1026
- func=self.__scheduler.pause, # Function to pause the scheduler
1136
+ func=schedule_pause, # Function to pause the scheduler
1027
1137
  trigger=DateTrigger(run_date=at), # Trigger type is 'date' for one-time execution
1028
- id=ListeningEvent.SCHEDULER_PAUSED.value, # Unique job ID for pausing the scheduler
1138
+ id="scheduler_pause_at", # Unique job ID for pausing the scheduler
1029
1139
  name="Pause Scheduler", # Descriptive name for the job
1030
1140
  replace_existing=True # Replace any existing job with the same ID
1031
1141
  )
1032
1142
 
1143
+ # Log the scheduled pause
1144
+ self.__logger.info(f"Scheduler pause scheduled for {at.strftime('%Y-%m-%d %H:%M:%S')}")
1145
+
1033
1146
  except Exception as e:
1147
+
1034
1148
  # Handle exceptions that may occur during job scheduling
1035
1149
  raise CLIOrionisRuntimeError(f"Failed to schedule scheduler pause: {str(e)}") from e
1036
1150
 
@@ -1060,7 +1174,7 @@ class Scheduler(ISchedule):
1060
1174
  Raises
1061
1175
  ------
1062
1176
  ValueError
1063
- If the 'at' parameter is not a valid datetime object.
1177
+ If the 'at' parameter is not a valid datetime object or is not in the future.
1064
1178
  CLIOrionisRuntimeError
1065
1179
  If the scheduler is not running or if an error occurs during job scheduling.
1066
1180
  """
@@ -1069,33 +1183,72 @@ class Scheduler(ISchedule):
1069
1183
  if not isinstance(at, datetime):
1070
1184
  raise ValueError("The 'at' parameter must be a datetime object.")
1071
1185
 
1072
- # Ensure the scheduler is running before scheduling a resume operation
1073
- if not self.__scheduler.running:
1074
- raise CLIOrionisRuntimeError("Cannot schedule resume operation: scheduler is not running.")
1186
+ # Define a function to resume the scheduler
1187
+ def schedule_resume():
1188
+
1189
+ # Only resume jobs if the scheduler is currently running
1190
+ if self.isRunning():
1191
+
1192
+ # Resume only jobs that were paused by pauseEverythingAt
1193
+ if self.__paused_by_pause_everything:
1194
+
1195
+ # Iterate through the set of paused job IDs and resume each one
1196
+ for job_id in list(self.__paused_by_pause_everything):
1197
+
1198
+ try:
1199
+
1200
+ # Resume the job and log the action
1201
+ self.__scheduler.resume_job(job_id)
1202
+ self.__logger.info(f"User job '{job_id}' has been resumed.")
1203
+
1204
+ # Excute the resumed listener
1205
+ self.__resumedListener(JobResume(
1206
+ code=EVENT_SCHEDULER_RESUMED,
1207
+ job_id=job_id
1208
+ ))
1209
+
1210
+ except Exception as e:
1211
+
1212
+ # Log a warning if resuming a job fails, but continue with others
1213
+ self.__logger.warning(f"Failed to resume job '{job_id}': {str(e)}")
1214
+
1215
+ # Clear the set after resuming all jobs
1216
+ self.__paused_by_pause_everything.clear()
1217
+
1218
+ # Log that all previously paused jobs have been resumed
1219
+ self.__logger.info("All previously paused user jobs have been resumed.")
1075
1220
 
1076
1221
  try:
1222
+
1077
1223
  # Remove any existing resume job to avoid conflicts
1078
1224
  try:
1079
- self.__scheduler.remove_job(ListeningEvent.SCHEDULER_RESUMED.value)
1225
+ self.__scheduler.remove_job("scheduler_resume_at")
1226
+
1227
+ # If the job doesn't exist, it's fine to proceed
1080
1228
  except:
1081
- pass # If the job doesn't exist, it's fine to proceed
1229
+ pass
1082
1230
 
1083
1231
  # Add a job to the scheduler to resume it at the specified datetime
1084
1232
  self.__scheduler.add_job(
1085
- func=self.__scheduler.resume, # Function to resume the scheduler
1233
+ func=schedule_resume, # Function to resume the scheduler
1086
1234
  trigger=DateTrigger(run_date=at), # Trigger type is 'date' for one-time execution
1087
- id=ListeningEvent.SCHEDULER_RESUMED.value, # Unique job ID for resuming the scheduler
1235
+ id="scheduler_resume_at", # Unique job ID for resuming the scheduler
1088
1236
  name="Resume Scheduler", # Descriptive name for the job
1089
1237
  replace_existing=True # Replace any existing job with the same ID
1090
1238
  )
1091
1239
 
1240
+ # Log the scheduled resume
1241
+ self.__logger.info(f"Scheduler resume scheduled for {at.strftime('%Y-%m-%d %H:%M:%S')}")
1242
+
1092
1243
  except Exception as e:
1244
+
1093
1245
  # Handle exceptions that may occur during job scheduling
1094
1246
  raise CLIOrionisRuntimeError(f"Failed to schedule scheduler resume: {str(e)}") from e
1095
1247
 
1096
1248
  def shutdownEverythingAt(
1097
1249
  self,
1098
- at: datetime
1250
+ at: datetime,
1251
+ wait: bool = True
1099
1252
  ) -> None:
1100
1253
  """
1101
1254
  Schedule the scheduler to shut down all operations at a specific datetime.
@@ -1109,6 +1262,9 @@ class Scheduler(ISchedule):
1109
1262
  at : datetime
1110
1263
  The datetime at which the scheduler should be shut down. Must be a valid
1111
1264
  datetime object.
1265
+ wait : bool, optional
1266
+ Whether to wait for currently running jobs to complete before shutdown.
1267
+ Default is True.
1112
1268
 
1113
1269
  Returns
1114
1270
  -------
@@ -1119,7 +1275,8 @@ class Scheduler(ISchedule):
1119
1275
  Raises
1120
1276
  ------
1121
1277
  ValueError
1122
- If the 'at' parameter is not a valid datetime object.
1278
+ If the 'at' parameter is not a valid datetime object or 'wait' is not boolean,
1279
+ or if the scheduled time is not in the future.
1123
1280
  CLIOrionisRuntimeError
1124
1281
  If the scheduler is not running or if an error occurs during job scheduling.
1125
1282
  """
@@ -1128,27 +1285,47 @@ class Scheduler(ISchedule):
1128
1285
  if not isinstance(at, datetime):
1129
1286
  raise ValueError("The 'at' parameter must be a datetime object.")
1130
1287
 
1131
- # Ensure the scheduler is running before scheduling a shutdown operation
1132
- if not self.__scheduler.running:
1133
- raise CLIOrionisRuntimeError("Cannot schedule shutdown operation: scheduler is not running.")
1288
+ # Validate that the 'wait' parameter is a boolean
1289
+ if not isinstance(wait, bool):
1290
+ raise ValueError("The 'wait' parameter must be a boolean value.")
1291
+
1292
+ # Define a function to shut down the scheduler
1293
+ def schedule_shutdown():
1294
+
1295
+ # Only shut down the scheduler if it is currently running
1296
+ if self.isRunning():
1297
+
1298
+ # Execute the shutdown process
1299
+ self.__scheduler.shutdown(wait=wait)
1300
+
1301
+ # Signal the stop event to break the wait in start()
1302
+ if self._stop_event and not self._stop_event.is_set():
1303
+ self._stop_event.set()
1134
1304
 
1135
1305
  try:
1306
+
1136
1307
  # Remove any existing shutdown job to avoid conflicts
1137
1308
  try:
1138
- self.__scheduler.remove_job(ListeningEvent.SCHEDULER_SHUTDOWN.value)
1309
+ self.__scheduler.remove_job("scheduler_shutdown_at")
1310
+
1311
+ # If the job doesn't exist, it's fine to proceed
1139
1312
  except:
1140
- pass # If the job doesn't exist, it's fine to proceed
1313
+ pass
1141
1314
 
1142
1315
  # Add a job to the scheduler to shut it down at the specified datetime
1143
1316
  self.__scheduler.add_job(
1144
- func=self.__scheduler.shutdown, # Function to shut down the scheduler
1317
+ func=schedule_shutdown, # Function to shut down the scheduler
1145
1318
  trigger=DateTrigger(run_date=at), # Trigger type is 'date' for one-time execution
1146
- id=ListeningEvent.SCHEDULER_SHUTDOWN.value, # Unique job ID for shutting down the scheduler
1319
+ id="scheduler_shutdown_at", # Unique job ID for shutting down the scheduler
1147
1320
  name="Shutdown Scheduler", # Descriptive name for the job
1148
1321
  replace_existing=True # Replace any existing job with the same ID
1149
1322
  )
1150
1323
 
1324
+ # Log the scheduled shutdown
1325
+ self.__logger.info(f"Scheduler shutdown scheduled for {at.strftime('%Y-%m-%d %H:%M:%S')} (wait={wait})")
1326
+
1151
1327
  except Exception as e:
1328
+
1152
1329
  # Handle exceptions that may occur during job scheduling
1153
1330
  raise CLIOrionisRuntimeError(f"Failed to schedule scheduler shutdown: {str(e)}") from e
1154
1331
 
@@ -1173,6 +1350,7 @@ class Scheduler(ISchedule):
1173
1350
  If the scheduler fails to start due to missing an asyncio event loop or other runtime issues.
1174
1351
  """
1175
1352
  try:
1353
+
1176
1354
  # Ensure the method is called within an asyncio event loop
1177
1355
  loop = asyncio.get_running_loop()
1178
1356
 
@@ -1186,11 +1364,11 @@ class Scheduler(ISchedule):
1186
1364
  self.__subscribeListeners()
1187
1365
 
1188
1366
  # Start the scheduler if it is not already running
1189
- if not self.__scheduler.running:
1367
+ if not self.isRunning():
1190
1368
  self.__scheduler.start()
1191
1369
 
1192
1370
  # Log that the scheduler is now active and waiting for events
1193
- self.__logger.info("Scheduler is now active and waiting for events...")
1371
+ self.__logger.info("Orionis Scheduler is now active and waiting for events...")
1194
1372
 
1195
1373
  try:
1196
1374
  # Wait for the stop event to be set, which signals a shutdown
@@ -1198,27 +1376,32 @@ class Scheduler(ISchedule):
1198
1376
  await self._stop_event.wait()
1199
1377
 
1200
1378
  except (KeyboardInterrupt, asyncio.CancelledError):
1379
+
1201
1380
  # Handle graceful shutdown when an interruption signal is received
1202
1381
  self.__logger.info("Received shutdown signal, stopping scheduler...")
1203
1382
  await self.shutdown(wait=True)
1204
1383
 
1205
1384
  except Exception as e:
1385
+
1206
1386
  # Log and raise any unexpected exceptions during scheduler operation
1207
1387
  self.__logger.error(f"Error during scheduler operation: {str(e)}")
1208
1388
  raise CLIOrionisRuntimeError(f"Scheduler operation failed: {str(e)}") from e
1209
1389
 
1210
1390
  finally:
1391
+
1211
1392
  # Ensure the scheduler is shut down properly, even if an error occurs
1212
1393
  if self.__scheduler.running:
1213
1394
  await self.shutdown(wait=False)
1214
1395
 
1215
1396
  except RuntimeError as e:
1397
+
1216
1398
  # Handle the case where no asyncio event loop is running
1217
1399
  if "no running event loop" in str(e):
1218
1400
  raise CLIOrionisRuntimeError("Scheduler must be started within an asyncio event loop") from e
1219
1401
  raise CLIOrionisRuntimeError(f"Failed to start the scheduler: {str(e)}") from e
1220
1402
 
1221
1403
  except Exception as e:
1404
+
1222
1405
  # Raise a runtime error for any other issues during startup
1223
1406
  raise CLIOrionisRuntimeError(f"Failed to start the scheduler: {str(e)}") from e
1224
1407
 
@@ -1251,10 +1434,11 @@ class Scheduler(ISchedule):
1251
1434
  raise ValueError("The 'wait' parameter must be a boolean value.")
1252
1435
 
1253
1436
  # If the scheduler is not running, there's nothing to shut down
1254
- if not self.__scheduler.running:
1437
+ if not self.isRunning():
1255
1438
  return
1256
1439
 
1257
1440
  try:
1441
+
1258
1442
  # Log the shutdown process
1259
1443
  self.__logger.info(f"Shutting down scheduler (wait={wait})...")
1260
1444
 
@@ -1269,9 +1453,12 @@ class Scheduler(ISchedule):
1269
1453
  if wait:
1270
1454
  await asyncio.sleep(0.1)
1271
1455
 
1456
+ # Log the successful shutdown
1272
1457
  self.__logger.info("Scheduler shutdown completed successfully.")
1273
1458
 
1274
1459
  except Exception as e:
1460
+
1461
+ # Handle exceptions that may occur during shutdown
1275
1462
  raise CLIOrionisRuntimeError(f"Failed to shut down the scheduler: {str(e)}") from e
1276
1463
 
1277
1464
  def pause(self, signature: str) -> bool:
@@ -1403,7 +1590,7 @@ class Scheduler(ISchedule):
1403
1590
 
1404
1591
  # Iterate through the internal jobs list to find and remove the job
1405
1592
  for job in self.__jobs:
1406
- if job['signature'] == signature:
1593
+ if job.signature == signature:
1407
1594
  self.__jobs.remove(job) # Remove the job from the internal list
1408
1595
  break
1409
1596
 
@@ -1469,4 +1656,192 @@ class Scheduler(ISchedule):
1469
1656
  })
1470
1657
 
1471
1658
  # Return the list of scheduled job details
1472
- return events
1659
+ return events
1660
+
1661
+ def cancelScheduledPause(self) -> bool:
1662
+ """
1663
+ Cancel a previously scheduled pause operation.
1664
+
1665
+ This method attempts to remove a job from the scheduler that was set to pause
1666
+ the scheduler at a specific time. If the job exists, it is removed, and a log entry
1667
+ is created to indicate the cancellation. If no such job exists, the method returns False.
1668
+
1669
+ Returns
1670
+ -------
1671
+ bool
1672
+ True if the scheduled pause job was successfully cancelled.
1673
+ False if no pause job was found or an error occurred during the cancellation process.
1674
+ """
1675
+ try:
1676
+
1677
+ # Remove any listener associated with the pause event
1678
+ listener = ListeningEvent.SCHEDULER_PAUSED.value
1679
+ if listener in self.__listeners:
1680
+ del self.__listeners[listener]
1681
+
1682
+ # Attempt to remove the pause job with the specific ID
1683
+ # if it exists
1684
+ try:
1685
+ self.__scheduler.remove_job("scheduler_pause_at")
1686
+ finally:
1687
+ pass
1688
+
1689
+ # Log the successful cancellation of the pause operation
1690
+ self.__logger.info("Scheduled pause operation cancelled.")
1691
+
1692
+ # Return True to indicate the pause job was successfully cancelled
1693
+ return True
1694
+
1695
+ except:
1696
+ # Return False if the pause job does not exist or an error occurred
1697
+ return False
1698
+
1699
+ def cancelScheduledResume(self) -> bool:
1700
+ """
1701
+ Cancel a previously scheduled resume operation.
1702
+
1703
+ This method attempts to remove a job from the scheduler that was set to resume
1704
+ the scheduler at a specific time. If the job exists, it is removed, and a log entry
1705
+ is created to indicate the cancellation. If no such job exists, the method returns False.
1706
+
1707
+ Returns
1708
+ -------
1709
+ bool
1710
+ True if the scheduled resume job was successfully cancelled.
1711
+ False if no resume job was found or an error occurred during the cancellation process.
1712
+ """
1713
+ try:
1714
+
1715
+ # Remove any listener associated with the resume event
1716
+ listener = ListeningEvent.SCHEDULER_RESUMED.value
1717
+ if listener in self.__listeners:
1718
+ del self.__listeners[listener]
1719
+
1720
+ # Attempt to remove the resume job with the specific ID
1721
+ # if it exists
1722
+ try:
1723
+ self.__scheduler.remove_job("scheduler_resume_at")
1724
+ finally:
1725
+ pass
1726
+
1727
+ # Log the successful cancellation of the resume operation
1728
+ self.__logger.info("Scheduled resume operation cancelled.")
1729
+
1730
+ # Return True to indicate the resume job was successfully cancelled
1731
+ return True
1732
+
1733
+ except:
1734
+
1735
+ # Return False if the resume job does not exist or an error occurred
1736
+ return False
1737
+
1738
+ def cancelScheduledShutdown(self) -> bool:
1739
+ """
1740
+ Cancel a previously scheduled shutdown operation.
1741
+
1742
+ This method attempts to remove a job from the scheduler that was set to shut down
1743
+ the scheduler at a specific time. If the job exists, it is removed, and a log entry
1744
+ is created to indicate the cancellation. If no such job exists, the method returns False.
1745
+
1746
+ Returns
1747
+ -------
1748
+ bool
1749
+ True if the scheduled shutdown job was successfully cancelled.
1750
+ False if no shutdown job was found or an error occurred during the cancellation process.
1751
+ """
1752
+ try:
1753
+
1754
+ # Remove any listener associated with the shutdown event
1755
+ listener = ListeningEvent.SCHEDULER_SHUTDOWN.value
1756
+ if listener in self.__listeners:
1757
+ del self.__listeners[listener]
1758
+
1759
+ # Attempt to remove the shutdown job with the specific ID
1760
+ # if it exists
1761
+ try:
1762
+ self.__scheduler.remove_job("scheduler_shutdown_at")
1763
+ finally:
1764
+ pass
1765
+
1766
+ # Log the successful cancellation of the shutdown operation
1767
+ self.__logger.info("Scheduled shutdown operation cancelled.")
1768
+
1769
+ # Return True to indicate the shutdown job was successfully cancelled
1770
+ return True
1771
+
1772
+ except:
1773
+
1774
+ # Return False if the shutdown job does not exist or an error occurred
1775
+ return False
1776
+
1777
+ def isRunning(self) -> bool:
1778
+ """
1779
+ Determine if the scheduler is currently active and running.
1780
+
1781
+ This method checks the internal state of the AsyncIOScheduler instance to determine
1782
+ whether it is currently running. The scheduler is considered running if it has been
1783
+ started and has not been paused or shut down.
1784
+
1785
+ Returns
1786
+ -------
1787
+ bool
1788
+ True if the scheduler is running, False otherwise.
1789
+ """
1790
+
1791
+ # Return the running state of the scheduler
1792
+ return self.__scheduler.running
1793
+
1794
+ def forceStop(self) -> None:
1795
+ """
1796
+ Forcefully stop the scheduler immediately without waiting for jobs to complete.
1797
+
1798
+ This method shuts down the AsyncIOScheduler instance without waiting for currently
1799
+ running jobs to finish. It is intended for emergency situations where an immediate
1800
+ stop is required. The method also signals the internal stop event to ensure that
1801
+ the scheduler's main loop is interrupted and the application can proceed with
1802
+ shutdown procedures.
1803
+
1804
+ Returns
1805
+ -------
1806
+ None
1807
+ This method does not return any value. It forcefully stops the scheduler and
1808
+ signals the stop event.
1809
+ """
1810
+
1811
+ # Check if the scheduler is currently running
1812
+ if self.__scheduler.running:
1813
+ # Shut down the scheduler immediately without waiting for jobs to complete
1814
+ self.__scheduler.shutdown(wait=False)
1815
+
1816
+ # Check if the stop event exists and has not already been set
1817
+ if self._stop_event and not self._stop_event.is_set():
1818
+ # Signal the stop event to interrupt the scheduler's main loop
1819
+ self._stop_event.set()
1820
+
1821
+ def stop(self) -> None:
1822
+ """
1823
+ Stop the scheduler synchronously by setting the stop event.
1824
+
1825
+ This method signals the scheduler to stop by setting the internal stop event.
1826
+ It can be called from non-async contexts to initiate a shutdown. If the asyncio
1827
+ event loop is running, the stop event is set in a thread-safe manner. Otherwise,
1828
+ the stop event is set directly.
1829
+
1830
+ Returns
1831
+ -------
1832
+ None
1833
+ This method does not return any value. It signals the scheduler to stop.
1834
+ """
1835
+
1836
+ # Check if the stop event exists and has not already been set
1837
+ if self._stop_event and not self._stop_event.is_set():
1838
+
1839
+ # Get the current asyncio event loop
1840
+ loop = asyncio.get_event_loop()
1841
+
1842
+ # If the event loop is running, set the stop event in a thread-safe manner
1843
+ if loop.is_running():
1844
+ loop.call_soon_threadsafe(self._stop_event.set)
1845
+ else:
1846
+ # Otherwise, set the stop event directly
1847
+ self._stop_event.set()