orionis 0.513.0__py3-none-any.whl → 0.515.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.
@@ -1,38 +1,45 @@
1
1
  import asyncio
2
2
  from datetime import datetime
3
3
  import logging
4
- from typing import Dict, List, Optional
4
+ from typing import Dict, List, Optional, Union
5
5
  import pytz
6
6
  from apscheduler.events import (
7
- EVENT_JOB_ERROR,
8
- EVENT_JOB_EXECUTED,
9
- EVENT_JOB_MISSED,
10
- EVENT_JOB_SUBMITTED,
7
+ EVENT_SCHEDULER_STARTED,
11
8
  EVENT_SCHEDULER_PAUSED,
12
9
  EVENT_SCHEDULER_RESUMED,
13
10
  EVENT_SCHEDULER_SHUTDOWN,
14
- EVENT_SCHEDULER_STARTED,
15
- EVENT_JOB_MAX_INSTANCES
11
+ EVENT_JOB_ERROR,
12
+ EVENT_JOB_SUBMITTED,
13
+ EVENT_JOB_EXECUTED,
14
+ EVENT_JOB_MISSED,
15
+ EVENT_JOB_MAX_INSTANCES,
16
+ EVENT_JOB_MODIFIED,
17
+ EVENT_JOB_REMOVED
16
18
  )
17
19
  from apscheduler.schedulers.asyncio import AsyncIOScheduler as APSAsyncIOScheduler
18
20
  from rich.console import Console
19
21
  from rich.panel import Panel
20
22
  from rich.text import Text
23
+ from orionis.console.contracts.event import IEvent
21
24
  from orionis.console.contracts.reactor import IReactor
22
25
  from orionis.console.contracts.schedule import ISchedule
26
+ from orionis.console.contracts.schedule_event_listener import IScheduleEventListener
23
27
  from orionis.console.entities.job_error import JobError
24
28
  from orionis.console.entities.job_executed import JobExecuted
25
29
  from orionis.console.entities.job_max_instances import JobMaxInstances
26
30
  from orionis.console.entities.job_missed import JobMissed
31
+ from orionis.console.entities.job_modified import JobModified
32
+ from orionis.console.entities.job_removed import JobRemoved
27
33
  from orionis.console.entities.job_submitted import JobSubmitted
28
34
  from orionis.console.entities.scheduler_paused import SchedulerPaused
29
35
  from orionis.console.entities.scheduler_resumed import SchedulerResumed
30
36
  from orionis.console.entities.scheduler_shutdown import SchedulerShutdown
31
37
  from orionis.console.entities.scheduler_started import SchedulerStarted
32
38
  from orionis.console.enums.listener import ListeningEvent
39
+ from orionis.console.enums.event import Event as EventEntity
33
40
  from orionis.console.exceptions import CLIOrionisRuntimeError
41
+ from orionis.console.exceptions.cli_orionis_value_error import CLIOrionisValueError
34
42
  from orionis.console.output.contracts.console import IConsole
35
- from orionis.console.tasks.event import Event
36
43
  from orionis.foundation.contracts.application import IApplication
37
44
  from orionis.services.log.contracts.log_service import ILogger
38
45
 
@@ -97,10 +104,10 @@ class Scheduler(ISchedule):
97
104
  self.__available_commands = self.__getCommands()
98
105
 
99
106
  # Initialize the jobs dictionary to keep track of scheduled jobs.
100
- self.__events: Dict[str, Event] = {}
107
+ self.__events: Dict[str, IEvent] = {}
101
108
 
102
109
  # Initialize the jobs list to keep track of all scheduled jobs.
103
- self.__jobs: List[dict] = []
110
+ self.__jobs: List[EventEntity] = []
104
111
 
105
112
  # Initialize the listeners dictionary to manage event listeners.
106
113
  self.__listeners: Dict[str, callable] = {}
@@ -121,10 +128,166 @@ class Scheduler(ISchedule):
121
128
  formatted as "YYYY-MM-DD HH:MM:SS".
122
129
  """
123
130
 
131
+ # Get the current time in the configured timezone
124
132
  tz = pytz.timezone(self.__app.config("app.timezone", "UTC"))
125
133
  now = datetime.now(tz)
134
+
135
+ # Format the current time as a string
126
136
  return now.strftime("%Y-%m-%d %H:%M:%S")
127
137
 
138
+ def __getCommands(
139
+ self
140
+ ) -> dict:
141
+ """
142
+ Retrieve available commands from the reactor and return them as a dictionary.
143
+
144
+ This method queries the reactor for all available jobs/commands, extracting their
145
+ signatures and descriptions. The result is a dictionary where each key is the command
146
+ signature and the value is another dictionary containing the command's signature and
147
+ its description.
148
+
149
+ Returns
150
+ -------
151
+ dict
152
+ A dictionary mapping command signatures to their details. Each value is a dictionary
153
+ with 'signature' and 'description' keys.
154
+ """
155
+
156
+ # Initialize the commands dictionary
157
+ commands = {}
158
+
159
+ # Iterate over all jobs provided by the reactor's info method
160
+ for job in self.__reactor.info():
161
+
162
+ # Store each job's signature and description in the commands dictionary
163
+ commands[job['signature']] = {
164
+ 'signature': job['signature'],
165
+ 'description': job.get('description', '')
166
+ }
167
+
168
+ # Return the commands dictionary
169
+ return commands
170
+
171
+ def __isAvailable(
172
+ self,
173
+ signature: str
174
+ ) -> bool:
175
+ """
176
+ Check if a command with the given signature is available.
177
+
178
+ This method iterates through the available commands and determines
179
+ whether the provided signature matches any registered command.
180
+
181
+ Parameters
182
+ ----------
183
+ signature : str
184
+ The signature of the command to check for availability.
185
+
186
+ Returns
187
+ -------
188
+ bool
189
+ True if the command with the specified signature exists and is available,
190
+ False otherwise.
191
+ """
192
+
193
+ # Iterate through all available command signatures
194
+ for command in self.__available_commands.keys():
195
+
196
+ # Return True if the signature matches an available command
197
+ if command == signature:
198
+ return True
199
+
200
+ # Return False if the signature is not found among available commands
201
+ return False
202
+
203
+ def __getDescription(
204
+ self,
205
+ signature: str
206
+ ) -> Optional[str]:
207
+ """
208
+ Retrieve the description of a command given its signature.
209
+
210
+ This method looks up the available commands dictionary and returns the description
211
+ associated with the provided command signature. If the signature does not exist,
212
+ it returns None.
213
+
214
+ Parameters
215
+ ----------
216
+ signature : str
217
+ The unique signature identifying the command.
218
+
219
+ Returns
220
+ -------
221
+ Optional[str]
222
+ The description of the command if found; otherwise, None.
223
+ """
224
+
225
+ # Attempt to retrieve the command entry from the available commands dictionary
226
+ command_entry = self.__available_commands.get(signature)
227
+
228
+ # Return the description if the command exists, otherwise return None
229
+ return command_entry['description'] if command_entry else None
230
+
231
+ def command(
232
+ self,
233
+ signature: str,
234
+ args: Optional[List[str]] = None
235
+ ) -> 'IEvent':
236
+ """
237
+ Prepare an Event instance for a given command signature and its arguments.
238
+
239
+ This method validates the provided command signature and arguments, ensuring
240
+ that the command exists among the registered commands and that the arguments
241
+ are in the correct format. If validation passes, it creates and returns an
242
+ Event object representing the scheduled command, including its signature,
243
+ arguments, and description.
244
+
245
+ Parameters
246
+ ----------
247
+ signature : str
248
+ The unique signature identifying the command to be scheduled. Must be a non-empty string.
249
+ args : Optional[List[str]], optional
250
+ A list of string arguments to be passed to the command. Defaults to None.
251
+
252
+ Returns
253
+ -------
254
+ Event
255
+ An Event instance containing the command signature, arguments, and its description.
256
+
257
+ Raises
258
+ ------
259
+ ValueError
260
+ If the command signature is not a non-empty string, if the arguments are not a list
261
+ of strings or None, or if the command does not exist among the registered commands.
262
+ """
263
+
264
+ # Prevent adding new commands while the scheduler is running
265
+ if self.__scheduler.running:
266
+ raise CLIOrionisRuntimeError("Cannot add new commands while the scheduler is running.")
267
+
268
+ # Validate that the command signature is a non-empty string
269
+ if not isinstance(signature, str) or not signature.strip():
270
+ raise CLIOrionisValueError("Command signature must be a non-empty string.")
271
+
272
+ # Ensure that arguments are either a list of strings or None
273
+ if args is not None and not isinstance(args, list):
274
+ raise CLIOrionisValueError("Arguments must be a list of strings or None.")
275
+
276
+ # Check if the command is available in the registered commands
277
+ if not self.__isAvailable(signature):
278
+ raise CLIOrionisValueError(f"The command '{signature}' is not available or does not exist.")
279
+
280
+ # Store the command and its arguments for scheduling
281
+ from orionis.console.tasks.event import Event
282
+ self.__events[signature] = Event(
283
+ signature=signature,
284
+ args=args or [],
285
+ purpose=self.__getDescription(signature)
286
+ )
287
+
288
+ # Return the Event instance for further scheduling configuration
289
+ return self.__events[signature]
290
+
128
291
  def __suscribeListeners(
129
292
  self
130
293
  ) -> None:
@@ -146,32 +309,131 @@ class Scheduler(ISchedule):
146
309
  This method does not return any value. It configures event listeners on the scheduler.
147
310
  """
148
311
 
149
- # Add a listener for the scheduler started event
150
312
  self.__scheduler.add_listener(self.__startedListener, EVENT_SCHEDULER_STARTED)
151
-
152
- # Add a listener for the scheduler shutdown event
313
+ self.__scheduler.add_listener(self.__pausedListener, EVENT_SCHEDULER_PAUSED)
314
+ self.__scheduler.add_listener(self.__resumedListener, EVENT_SCHEDULER_RESUMED)
153
315
  self.__scheduler.add_listener(self.__shutdownListener, EVENT_SCHEDULER_SHUTDOWN)
316
+ self.__scheduler.add_listener(self.__errorListener, EVENT_JOB_ERROR)
317
+ self.__scheduler.add_listener(self.__submittedListener, EVENT_JOB_SUBMITTED)
318
+ self.__scheduler.add_listener(self.__executedListener, EVENT_JOB_EXECUTED)
319
+ self.__scheduler.add_listener(self.__missedListener, EVENT_JOB_MISSED)
320
+ self.__scheduler.add_listener(self.__maxInstancesListener, EVENT_JOB_MAX_INSTANCES)
321
+ self.__scheduler.add_listener(self.__modifiedListener, EVENT_JOB_MODIFIED)
322
+ self.__scheduler.add_listener(self.__removedListener, EVENT_JOB_REMOVED)
154
323
 
155
- # Add a listener for the scheduler paused event
156
- self.__scheduler.add_listener(self.__pausedListener, EVENT_SCHEDULER_PAUSED)
324
+ def __globalCallableListener(
325
+ self,
326
+ event_data: Optional[Union[SchedulerStarted, SchedulerPaused, SchedulerResumed, SchedulerShutdown, JobError]],
327
+ listening_vent: ListeningEvent
328
+ ) -> None:
329
+ """
330
+ Invoke registered listeners for global scheduler events.
157
331
 
158
- # Add a listener for the scheduler resumed event
159
- self.__scheduler.add_listener(self.__resumedListener, EVENT_SCHEDULER_RESUMED)
332
+ This method handles global scheduler events such as when the scheduler starts, pauses, resumes,
333
+ or shuts down. It checks if a listener is registered for the specified event and invokes it if callable.
334
+ The listener can be either a coroutine or a regular function.
160
335
 
161
- # Add a listener for job submission events
162
- self.__scheduler.add_listener(self.__submittedListener, EVENT_JOB_SUBMITTED)
336
+ Parameters
337
+ ----------
338
+ event_data : Optional[Union[SchedulerStarted, SchedulerPaused, SchedulerResumed, SchedulerShutdown, JobError]]
339
+ The event data associated with the global scheduler event. This can include details about the event,
340
+ such as its type and context. If no specific data is available, this parameter can be None.
341
+ listening_vent : ListeningEvent
342
+ An instance of the ListeningEvent enum representing the global scheduler event to handle.
163
343
 
164
- # Add a listener for job execution events
165
- self.__scheduler.add_listener(self.__executedListener, EVENT_JOB_EXECUTED)
344
+ Returns
345
+ -------
346
+ None
347
+ This method does not return any value. It invokes the registered listener for the specified event,
348
+ if one exists.
166
349
 
167
- # Add a listener for missed job events
168
- self.__scheduler.add_listener(self.__missedListener, EVENT_JOB_MISSED)
350
+ Raises
351
+ ------
352
+ CLIOrionisValueError
353
+ If the provided `listening_vent` is not an instance of ListeningEvent.
354
+ """
169
355
 
170
- # Add a listener for job error events
171
- self.__scheduler.add_listener(self.__errorListener, EVENT_JOB_ERROR)
356
+ # Validate that the provided event is an instance of ListeningEvent
357
+ if not isinstance(listening_vent, ListeningEvent):
358
+ raise CLIOrionisValueError("The event must be an instance of ListeningEvent.")
172
359
 
173
- # Add a listener for job max instances events
174
- self.__scheduler.add_listener(self.__maxInstancesListener, EVENT_JOB_MAX_INSTANCES)
360
+ # Retrieve the global identifier for the event from the ListeningEvent enum
361
+ scheduler_event = listening_vent.value
362
+
363
+ # Check if a listener is registered for the specified event
364
+ if scheduler_event in self.__listeners:
365
+ listener = self.__listeners[scheduler_event]
366
+
367
+ # Ensure the listener is callable before invoking it
368
+ if callable(listener):
369
+ # If the listener is a coroutine, schedule it as an asyncio task
370
+ if asyncio.iscoroutinefunction(listener):
371
+ asyncio.create_task(listener(event_data, self))
372
+ # Otherwise, invoke the listener directly as a regular function
373
+ else:
374
+ listener(event_data, self)
375
+
376
+ def __taskCallableListener(
377
+ self,
378
+ event_data: Optional[Union[JobError, JobExecuted, JobSubmitted, JobMissed, JobMaxInstances]],
379
+ listening_vent: ListeningEvent
380
+ ) -> None:
381
+ """
382
+ Invoke registered listeners for specific task/job events.
383
+
384
+ This method handles task/job-specific events such as job errors, executions, submissions,
385
+ missed jobs, and max instance violations. It checks if a listener is registered for the
386
+ specific job ID associated with the event and invokes the appropriate method on the listener
387
+ if callable. The listener can be either a coroutine or a regular function.
388
+
389
+ Parameters
390
+ ----------
391
+ event_data : Optional[Union[JobError, JobExecuted, JobSubmitted, JobMissed, JobMaxInstances]]
392
+ The event data associated with the task/job event. This includes details about the job,
393
+ such as its ID, exception (if any), and other context. If no specific data is available,
394
+ this parameter can be None.
395
+ listening_vent : ListeningEvent
396
+ An instance of the ListeningEvent enum representing the task/job event to handle.
397
+
398
+ Returns
399
+ -------
400
+ None
401
+ This method does not return any value. It invokes the registered listener for the
402
+ specified job event, if one exists.
403
+
404
+ Raises
405
+ ------
406
+ CLIOrionisValueError
407
+ If the provided `listening_vent` is not an instance of ListeningEvent.
408
+ """
409
+
410
+ # Validate that the provided event is an instance of ListeningEvent
411
+ if not isinstance(listening_vent, ListeningEvent):
412
+ raise CLIOrionisValueError("The event must be an instance of ListeningEvent.")
413
+
414
+ # Retrieve the global identifier for the event from the ListeningEvent enum
415
+ scheduler_event = listening_vent.value
416
+
417
+ # Check if a listener is registered for the specific job ID in the event data
418
+ if event_data.job_id in self.__listeners:
419
+
420
+ # Retrieve the listener for the specific job ID
421
+ listener = self.__listeners[event_data.job_id]
422
+
423
+ # Check if the listener is an instance of IScheduleEventListener
424
+ if isinstance(listener, IScheduleEventListener):
425
+
426
+ # Check if the listener has a method corresponding to the event type
427
+ if hasattr(listener, scheduler_event) and callable(getattr(listener, scheduler_event)):
428
+ listener_method = getattr(listener, scheduler_event)
429
+
430
+ # Invoke the listener method, handling both coroutine and regular functions
431
+ if asyncio.iscoroutinefunction(listener_method):
432
+ # Schedule the coroutine listener method as an asyncio task
433
+ asyncio.create_task(listener_method(event_data, self))
434
+ else:
435
+ # Call the regular listener method directly
436
+ listener_method(event_data, self)
175
437
 
176
438
  def __startedListener(
177
439
  self,
@@ -225,68 +487,8 @@ class Scheduler(ISchedule):
225
487
  # Add another blank line for better formatting
226
488
  self.__rich_console.line()
227
489
 
228
- # Retrieve the global identifier for the scheduler started event
229
- scheduler_started = ListeningEvent.SCHEDULER_STARTED.value
230
-
231
490
  # Check if a listener is registered for the scheduler started event
232
- if scheduler_started in self.__listeners:
233
- listener = self.__listeners[scheduler_started]
234
-
235
- # Ensure the listener is callable before invoking it
236
- if callable(listener):
237
-
238
- # Invoke the registered listener with the event details
239
- listener(event)
240
-
241
- def __shutdownListener(
242
- self,
243
- event: SchedulerShutdown
244
- ) -> None:
245
- """
246
- Handle the scheduler shutdown event for logging and invoking registered listeners.
247
-
248
- This method is triggered when the scheduler shuts down. It logs an informational
249
- message indicating that the scheduler has shut down successfully and displays
250
- a formatted message on the rich console. If a listener is registered for the
251
- scheduler shutdown event, it invokes the listener with the event details.
252
-
253
- Parameters
254
- ----------
255
- event : SchedulerShutdown
256
- An event object containing details about the scheduler shutdown event.
257
-
258
- Returns
259
- -------
260
- None
261
- This method does not return any value. It performs logging, displays
262
- a message on the console, and invokes any registered listener for the
263
- scheduler shutdown event.
264
- """
265
-
266
- # Get the current time in the configured timezone
267
- now = self.__getCurrentTime()
268
-
269
- # Create a shutdown message
270
- message = f"Orionis Scheduler shut down successfully at {now}."
271
-
272
- # Log an informational message indicating that the scheduler has shut down
273
- self.__logger.info(message)
274
-
275
- # Display a shutdown message for the scheduler worker on console
276
- if self.__app.config('app.debug', False):
277
- self.__console.info(message)
278
-
279
- # Retrieve the global identifier for the scheduler shutdown event
280
- scheduler_shutdown = GlobalListener.SCHEDULER_SHUTDOWN.value
281
-
282
- # Check if a listener is registered for the scheduler shutdown event
283
- if scheduler_shutdown in self.__listeners:
284
- listener = self.__listeners[scheduler_shutdown]
285
-
286
- # Ensure the listener is callable before invoking it
287
- if callable(listener):
288
- # Invoke the registered listener with the event details
289
- listener(event)
491
+ self.__globalCallableListener(event, ListeningEvent.SCHEDULER_STARTED)
290
492
 
291
493
  def __pausedListener(
292
494
  self,
@@ -326,17 +528,8 @@ class Scheduler(ISchedule):
326
528
  if self.__app.config('app.debug', False):
327
529
  self.__console.info(message)
328
530
 
329
- # Retrieve the global identifier for the scheduler paused event
330
- scheduler_paused = GlobalListener.SCHEDULER_PAUSED.value
331
-
332
- # Check if a listener is registered for the scheduler paused event
333
- if scheduler_paused in self.__listeners:
334
- listener = self.__listeners[scheduler_paused]
335
-
336
- # Ensure the listener is callable before invoking it
337
- if callable(listener):
338
- # Invoke the registered listener with the event details
339
- listener(event)
531
+ # Check if a listener is registered for the scheduler started event
532
+ self.__globalCallableListener(event, ListeningEvent.SCHEDULER_PAUSED)
340
533
 
341
534
  def __resumedListener(
342
535
  self,
@@ -376,17 +569,90 @@ class Scheduler(ISchedule):
376
569
  if self.__app.config('app.debug', False):
377
570
  self.__console.info(message)
378
571
 
379
- # Retrieve the global identifier for the scheduler resumed event
380
- scheduler_resumed = GlobalListener.SCHEDULER_RESUMED.value
572
+ # Check if a listener is registered for the scheduler started event
573
+ self.__globalCallableListener(event, ListeningEvent.SCHEDULER_RESUMED)
574
+
575
+ def __shutdownListener(
576
+ self,
577
+ event: SchedulerShutdown
578
+ ) -> None:
579
+ """
580
+ Handle the scheduler shutdown event for logging and invoking registered listeners.
581
+
582
+ This method is triggered when the scheduler shuts down. It logs an informational
583
+ message indicating that the scheduler has shut down successfully and displays
584
+ a formatted message on the rich console. If a listener is registered for the
585
+ scheduler shutdown event, it invokes the listener with the event details.
586
+
587
+ Parameters
588
+ ----------
589
+ event : SchedulerShutdown
590
+ An event object containing details about the scheduler shutdown event.
591
+
592
+ Returns
593
+ -------
594
+ None
595
+ This method does not return any value. It performs logging, displays
596
+ a message on the console, and invokes any registered listener for the
597
+ scheduler shutdown event.
598
+ """
599
+
600
+ # Get the current time in the configured timezone
601
+ now = self.__getCurrentTime()
602
+
603
+ # Create a shutdown message
604
+ message = f"Orionis Scheduler shut down successfully at {now}."
605
+
606
+ # Log an informational message indicating that the scheduler has shut down
607
+ self.__logger.info(message)
608
+
609
+ # Display a shutdown message for the scheduler worker on console
610
+ if self.__app.config('app.debug', False):
611
+ self.__console.info(message)
612
+
613
+ # Check if a listener is registered for the scheduler started event
614
+ self.__globalCallableListener(event, ListeningEvent.SCHEDULER_SHUTDOWN)
615
+
616
+ def __errorListener(
617
+ self,
618
+ event: JobError
619
+ ) -> None:
620
+ """
621
+ Handle job error events for logging and error reporting.
622
+
623
+ This method is triggered when a job execution results in an error. It logs an error
624
+ message indicating the job ID and the exception raised. If the application is in
625
+ debug mode, it also reports the error using the error reporter. Additionally, if a
626
+ listener is registered for the errored job, it invokes the listener with the event details.
627
+
628
+ Parameters
629
+ ----------
630
+ event : JobError
631
+ An instance of the JobError event containing details about the errored job,
632
+ including its ID and the exception raised.
633
+
634
+ Returns
635
+ -------
636
+ None
637
+ This method does not return any value. It performs logging, error reporting,
638
+ and listener invocation for the job error event.
639
+ """
381
640
 
382
- # Check if a listener is registered for the scheduler resumed event
383
- if scheduler_resumed in self.__listeners:
384
- listener = self.__listeners[scheduler_resumed]
641
+ # Create an error message
642
+ message = f"Task {event.job_id} raised an exception: {event.exception}"
643
+
644
+ # Log an error message indicating that the job raised an exception
645
+ self.__logger.error(message)
646
+
647
+ # If the application is in debug mode, display a message on the console
648
+ if self.__app.config('app.debug', False):
649
+ self.__console.error(message)
650
+
651
+ # If a listener is registered for this job ID, invoke the listener with the event details
652
+ self.__taskCallableListener(event, ListeningEvent.JOB_ON_FAILURE)
385
653
 
386
- # Ensure the listener is callable before invoking it
387
- if callable(listener):
388
- # Invoke the registered listener with the event details
389
- listener(event)
654
+ # Check if a listener is registered for the scheduler started event
655
+ self.__globalCallableListener(event, ListeningEvent.SCHEDULER_ERROR)
390
656
 
391
657
  def __submittedListener(
392
658
  self,
@@ -424,14 +690,7 @@ class Scheduler(ISchedule):
424
690
  self.__console.info(message)
425
691
 
426
692
  # If a listener is registered for this job ID, invoke the listener with the event details
427
- if event.job_id in self.__listeners:
428
- listener = self.__listeners[event.job_id]
429
-
430
- # Ensure the listener is callable before invoking it
431
- if callable(listener):
432
-
433
- # Invoke the registered listener with the event details
434
- listener(event)
693
+ self.__taskCallableListener(event, ListeningEvent.JOB_BEFORE)
435
694
 
436
695
  def __executedListener(
437
696
  self,
@@ -470,14 +729,7 @@ class Scheduler(ISchedule):
470
729
  self.__console.info(message)
471
730
 
472
731
  # If a listener is registered for this job ID, invoke the listener with the event details
473
- if event.job_id in self.__listeners:
474
- listener = self.__listeners[event.job_id]
475
-
476
- # Ensure the listener is callable before invoking it
477
- if callable(listener):
478
-
479
- # Invoke the registered listener with the event details
480
- listener(event)
732
+ self.__taskCallableListener(event, ListeningEvent.JOB_AFTER)
481
733
 
482
734
  def __missedListener(
483
735
  self,
@@ -516,59 +768,7 @@ class Scheduler(ISchedule):
516
768
  self.__console.warning(message)
517
769
 
518
770
  # If a listener is registered for this job ID, invoke the listener with the event details
519
- if event.job_id in self.__listeners:
520
- listener = self.__listeners[event.job_id]
521
-
522
- # Ensure the listener is callable before invoking it
523
- if callable(listener):
524
-
525
- # Invoke the registered listener with the event details
526
- listener(event)
527
-
528
- def __errorListener(
529
- self,
530
- event: JobError
531
- ) -> None:
532
- """
533
- Handle job error events for logging and error reporting.
534
-
535
- This method is triggered when a job execution results in an error. It logs an error
536
- message indicating the job ID and the exception raised. If the application is in
537
- debug mode, it also reports the error using the error reporter. Additionally, if a
538
- listener is registered for the errored job, it invokes the listener with the event details.
539
-
540
- Parameters
541
- ----------
542
- event : JobError
543
- An instance of the JobError event containing details about the errored job,
544
- including its ID and the exception raised.
545
-
546
- Returns
547
- -------
548
- None
549
- This method does not return any value. It performs logging, error reporting,
550
- and listener invocation for the job error event.
551
- """
552
-
553
- # Create an error message
554
- message = f"Task {event.job_id} raised an exception: {event.exception}"
555
-
556
- # Log an error message indicating that the job raised an exception
557
- self.__logger.error(message)
558
-
559
- # If the application is in debug mode, display a message on the console
560
- if self.__app.config('app.debug', False):
561
- self.__console.error(message)
562
-
563
- # If a listener is registered for this job ID, invoke the listener with the event details
564
- if event.job_id in self.__listeners:
565
- listener = self.__listeners[event.job_id]
566
-
567
- # Ensure the listener is callable before invoking it
568
- if callable(listener):
569
-
570
- # Invoke the registered listener with the event details
571
- listener(event)
771
+ self.__taskCallableListener(event, ListeningEvent.JOB_ON_MISSED)
572
772
 
573
773
  def __maxInstancesListener(
574
774
  self,
@@ -607,107 +807,87 @@ class Scheduler(ISchedule):
607
807
  self.__console.error(message)
608
808
 
609
809
  # If a listener is registered for this job ID, invoke the listener with the event details
610
- if event.job_id in self.__listeners:
611
- listener = self.__listeners[event.job_id]
612
-
613
- # Ensure the listener is callable before invoking it
614
- if callable(listener):
615
-
616
- # Invoke the registered listener with the event details
617
- listener(event)
618
-
619
- def __getCommands(
620
- self
621
- ) -> dict:
622
- """
623
- Retrieve available commands from the reactor and return them as a dictionary.
624
-
625
- This method queries the reactor for all available jobs/commands, extracting their
626
- signatures and descriptions. The result is a dictionary where each key is the command
627
- signature and the value is another dictionary containing the command's signature and
628
- its description.
629
-
630
- Returns
631
- -------
632
- dict
633
- A dictionary mapping command signatures to their details. Each value is a dictionary
634
- with 'signature' and 'description' keys.
635
- """
636
-
637
- # Initialize the commands dictionary
638
- commands = {}
639
-
640
- # Iterate over all jobs provided by the reactor's info method
641
- for job in self.__reactor.info():
642
-
643
- # Store each job's signature and description in the commands dictionary
644
- commands[job['signature']] = {
645
- 'signature': job['signature'],
646
- 'description': job.get('description', '')
647
- }
648
-
649
- # Return the commands dictionary
650
- return commands
810
+ self.__taskCallableListener(event, ListeningEvent.JOB_ON_MAXINSTANCES)
651
811
 
652
- def __isAvailable(
812
+ def __modifiedListener(
653
813
  self,
654
- signature: str
655
- ) -> bool:
814
+ event: JobModified
815
+ ) -> None:
656
816
  """
657
- Check if a command with the given signature is available.
817
+ Handle job modified events for logging and error reporting.
658
818
 
659
- This method iterates through the available commands and determines
660
- whether the provided signature matches any registered command.
819
+ This method is triggered when a job is modified. It logs an informational
820
+ message indicating that the job has been modified successfully. If the application
821
+ is in debug mode, it also displays a message on the console. Additionally, if a
822
+ listener is registered for the modified job, it invokes the listener with the
823
+ event details.
661
824
 
662
825
  Parameters
663
826
  ----------
664
- signature : str
665
- The signature of the command to check for availability.
827
+ event : JobModified
828
+ An instance of the JobModified event containing details about the modified job,
829
+ including its ID and other relevant information.
666
830
 
667
831
  Returns
668
832
  -------
669
- bool
670
- True if the command with the specified signature exists and is available,
671
- False otherwise.
833
+ None
834
+ This method does not return any value. It performs logging, error reporting,
835
+ and listener invocation for the job modified event.
672
836
  """
673
837
 
674
- # Iterate through all available command signatures
675
- for command in self.__available_commands.keys():
838
+ # Create a modified message
839
+ message = f"Task {event.job_id} has been modified."
676
840
 
677
- # Return True if the signature matches an available command
678
- if command == signature:
679
- return True
841
+ # Log an informational message indicating that the job has been modified
842
+ self.__logger.info(message)
680
843
 
681
- # Return False if the signature is not found among available commands
682
- return False
844
+ # If the application is in debug mode, display a message on the console
845
+ if self.__app.config('app.debug', False):
846
+ self.__console.info(message)
683
847
 
684
- def __getDescription(
848
+ # If a listener is registered for this job ID, invoke the listener with the event details
849
+ if event.next_run_time is None:
850
+ self.__taskCallableListener(event, ListeningEvent.JOB_ON_PAUSED)
851
+ else:
852
+ self.__taskCallableListener(event, ListeningEvent.JOB_ON_RESUMED)
853
+
854
+ def __removedListener(
685
855
  self,
686
- signature: str
687
- ) -> Optional[str]:
856
+ event: JobRemoved
857
+ ) -> None:
688
858
  """
689
- Retrieve the description of a command given its signature.
859
+ Handle job removal events for logging and invoking registered listeners.
690
860
 
691
- This method looks up the available commands dictionary and returns the description
692
- associated with the provided command signature. If the signature does not exist,
693
- it returns None.
861
+ This method is triggered when a job is removed from the scheduler. It logs an informational
862
+ message indicating that the job has been removed successfully. If the application is in debug
863
+ mode, it displays a message on the console. Additionally, if a listener is registered for the
864
+ removed job, it invokes the listener with the event details.
694
865
 
695
866
  Parameters
696
867
  ----------
697
- signature : str
698
- The unique signature identifying the command.
868
+ event : JobRemoved
869
+ An instance of the JobRemoved event containing details about the removed job,
870
+ including its ID and other relevant information.
699
871
 
700
872
  Returns
701
873
  -------
702
- Optional[str]
703
- The description of the command if found; otherwise, None.
874
+ None
875
+ This method does not return any value. It performs logging and invokes any registered
876
+ listener for the job removal event.
704
877
  """
705
878
 
706
- # Attempt to retrieve the command entry from the available commands dictionary
707
- command_entry = self.__available_commands.get(signature)
879
+ # Create a message indicating that the job has been removed
880
+ message = f"Task {event.job_id} has been removed."
708
881
 
709
- # Return the description if the command exists, otherwise return None
710
- return command_entry['description'] if command_entry else None
882
+ # Log the removal of the job
883
+ self.__logger.info(message)
884
+
885
+ # If the application is in debug mode, display the message on the console
886
+ if self.__app.config('app.debug', False):
887
+ self.__console.info(message)
888
+
889
+ # If a listener is registered for this job ID, invoke the listener with the event details
890
+ self.__taskCallableListener(event, ListeningEvent.JOB_ON_REMOVED)
711
891
 
712
892
  def __loadEvents(
713
893
  self
@@ -735,7 +915,7 @@ class Scheduler(ISchedule):
735
915
  entity = event.toEntity()
736
916
 
737
917
  # Add the job to the internal jobs list
738
- self.__jobs.append(entity.toDict())
918
+ self.__jobs.append(entity)
739
919
 
740
920
  # Create a unique key for the job based on its signature
741
921
  self.__scheduler.add_job(
@@ -749,82 +929,32 @@ class Scheduler(ISchedule):
749
929
  replace_existing=True
750
930
  )
751
931
 
752
- def command(
753
- self,
754
- signature: str,
755
- args: Optional[List[str]] = None
756
- ) -> 'Event':
757
- """
758
- Prepare an Event instance for a given command signature and its arguments.
759
-
760
- This method validates the provided command signature and arguments, ensuring
761
- that the command exists among the registered commands and that the arguments
762
- are in the correct format. If validation passes, it creates and returns an
763
- Event object representing the scheduled command, including its signature,
764
- arguments, and description.
765
-
766
- Parameters
767
- ----------
768
- signature : str
769
- The unique signature identifying the command to be scheduled. Must be a non-empty string.
770
- args : Optional[List[str]], optional
771
- A list of string arguments to be passed to the command. Defaults to None.
772
-
773
- Returns
774
- -------
775
- Event
776
- An Event instance containing the command signature, arguments, and its description.
777
-
778
- Raises
779
- ------
780
- ValueError
781
- If the command signature is not a non-empty string, if the arguments are not a list
782
- of strings or None, or if the command does not exist among the registered commands.
783
- """
784
-
785
- # Validate that the command signature is a non-empty string
786
- if not isinstance(signature, str) or not signature.strip():
787
- raise ValueError("Command signature must be a non-empty string.")
788
-
789
- # Ensure that arguments are either a list of strings or None
790
- if args is not None and not isinstance(args, list):
791
- raise ValueError("Arguments must be a list of strings or None.")
792
-
793
- # Check if the command is available in the registered commands
794
- if not self.__isAvailable(signature):
795
- raise ValueError(f"The command '{signature}' is not available or does not exist.")
796
-
797
- # Store the command and its arguments for scheduling
798
- self.__events[signature] = Event(
799
- signature=signature,
800
- args=args or [],
801
- purpose=self.__getDescription(signature)
802
- )
803
-
804
- # Return the Event instance for further scheduling configuration
805
- return self.__events[signature]
932
+ # If a listener is associated with the event, register it
933
+ if entity.listener:
934
+ self._setListener(signature, entity.listener)
806
935
 
807
- def _setListener(
936
+ def setListener(
808
937
  self,
809
- event: str,
810
- listener: callable
938
+ event: Union[str, ListeningEvent],
939
+ listener: Union[IScheduleEventListener, callable]
811
940
  ) -> None:
812
941
  """
813
942
  Register a listener callback for a specific scheduler event.
814
943
 
815
- This method allows the registration of a callable listener function that will be
816
- invoked when the specified scheduler event occurs. The event can be one of the
817
- predefined global events or a specific job ID. The listener must be a callable
818
- function that accepts a single argument, which will be the event object.
944
+ This method registers a listener function or an instance of IScheduleEventListener
945
+ to be invoked when the specified scheduler event occurs. The event can be a global
946
+ event name (e.g., 'scheduler_started') or a specific job ID. The listener must be
947
+ callable and should accept the event object as a parameter.
819
948
 
820
949
  Parameters
821
950
  ----------
822
951
  event : str
823
952
  The name of the event to listen for. This can be a global event name (e.g., 'scheduler_started')
824
953
  or a specific job ID.
825
- listener : callable
826
- A callable function that will be invoked when the specified event occurs.
827
- The function should accept one parameter, which will be the event object.
954
+ listener : IScheduleEventListener or callable
955
+ A callable function or an instance of IScheduleEventListener that will be invoked
956
+ when the specified event occurs. The listener should accept one parameter, which
957
+ will be the event object.
828
958
 
829
959
  Returns
830
960
  -------
@@ -834,18 +964,23 @@ class Scheduler(ISchedule):
834
964
  Raises
835
965
  ------
836
966
  ValueError
837
- If the event name is not a non-empty string or if the listener is not callable.
967
+ If the event name is not a non-empty string or if the listener is not callable
968
+ or an instance of IScheduleEventListener.
838
969
  """
839
970
 
971
+ # If the event is an instance of ListeningEvent, extract its value
972
+ if isinstance(event, ListeningEvent):
973
+ event = event.value
974
+
840
975
  # Validate that the event name is a non-empty string
841
976
  if not isinstance(event, str) or not event.strip():
842
977
  raise ValueError("Event name must be a non-empty string.")
843
978
 
844
- # Validate that the listener is a callable function
845
- if not callable(listener):
846
- raise ValueError("Listener must be a callable function.")
979
+ # Validate that the listener is either callable or an instance of IScheduleEventListener
980
+ if not callable(listener) and not isinstance(listener, IScheduleEventListener):
981
+ raise ValueError("Listener must be a callable function or an instance of IScheduleEventListener.")
847
982
 
848
- # Register the listener for the specified event
983
+ # Register the listener for the specified event in the internal listeners dictionary
849
984
  self.__listeners[event] = listener
850
985
 
851
986
  def pauseEverythingAt(
@@ -969,7 +1104,7 @@ class Scheduler(ISchedule):
969
1104
 
970
1105
  # Add a job to the scheduler to shut it down at the specified datetime
971
1106
  self.__scheduler.add_job(
972
- func=self.shutdown, # Function to shut down the scheduler
1107
+ func=self.__scheduler.shutdown, # Function to shut down the scheduler
973
1108
  trigger='date', # Trigger type is 'date' for one-time execution
974
1109
  run_date=at, # The datetime at which the job will run
975
1110
  id=f"shutdown_scheduler_at_{at.isoformat()}", # Unique job ID based on the datetime
@@ -1011,13 +1146,14 @@ class Scheduler(ISchedule):
1011
1146
  try:
1012
1147
 
1013
1148
  # Run indefinitely until interrupted
1014
- while self.__scheduler.running and self.__scheduler.get_jobs():
1149
+ while self.__scheduler.running:
1015
1150
  await asyncio.sleep(1)
1016
1151
 
1017
1152
  except (KeyboardInterrupt, asyncio.CancelledError):
1018
-
1019
- # Handle graceful shutdown on keyboard interrupt or cancellation
1020
1153
  await self.shutdown()
1154
+ except Exception as e:
1155
+ raise CLIOrionisRuntimeError(f"Failed to start the scheduler: {str(e)}") from e
1156
+
1021
1157
 
1022
1158
  except Exception as e:
1023
1159
 
@@ -1028,92 +1164,186 @@ class Scheduler(ISchedule):
1028
1164
  """
1029
1165
  Shut down the AsyncIO scheduler instance asynchronously.
1030
1166
 
1031
- This method gracefully stops the AsyncIOScheduler that handles asynchronous job execution.
1032
- Using async ensures proper cleanup in asyncio environments.
1167
+ This method gracefully stops the AsyncIOScheduler that manages asynchronous job execution.
1168
+ It ensures proper cleanup in asyncio environments and allows for an optional wait period
1169
+ to complete currently executing jobs before shutting down.
1033
1170
 
1034
1171
  Parameters
1035
1172
  ----------
1036
1173
  wait : bool, optional
1037
- If True, the method will wait until all currently executing jobs are completed before shutting down the scheduler.
1038
- If False, the scheduler will be shut down immediately without waiting for running jobs to finish. Default is True.
1174
+ If True, the method waits until all currently executing jobs are completed before shutting down the scheduler.
1175
+ If False, the scheduler shuts down immediately without waiting for running jobs to finish. Default is True.
1039
1176
 
1040
1177
  Returns
1041
1178
  -------
1042
1179
  None
1043
- This method does not return any value. It shuts down the AsyncIO scheduler.
1180
+ This method does not return any value. It performs the shutdown operation for the AsyncIO scheduler.
1181
+
1182
+ Raises
1183
+ ------
1184
+ ValueError
1185
+ If the 'wait' parameter is not a boolean value.
1186
+ CLIOrionisRuntimeError
1187
+ If an error occurs during the shutdown process.
1044
1188
  """
1045
1189
 
1046
- # Validate that the wait parameter is a boolean.
1190
+ # Ensure the 'wait' parameter is a boolean value.
1047
1191
  if not isinstance(wait, bool):
1048
1192
  raise ValueError("The 'wait' parameter must be a boolean value.")
1049
1193
 
1194
+ # If the scheduler is not running, there is nothing to shut down.
1195
+ if not self.__scheduler.running:
1196
+ return
1197
+
1198
+ try:
1199
+ # Shut down the AsyncIOScheduler. If 'wait' is True, it waits for currently executing jobs to finish.
1200
+ self.__scheduler.shutdown(wait=wait)
1201
+
1202
+ # If 'wait' is True, allow a small delay to ensure proper cleanup of resources.
1203
+ if wait:
1204
+ await asyncio.sleep(0)
1205
+
1206
+ except Exception as e:
1207
+
1208
+ # Raise a runtime error if the shutdown process fails.
1209
+ raise CLIOrionisRuntimeError(f"Failed to shut down the scheduler: {str(e)}") from e
1210
+
1211
+ def pause(self, signature: str) -> bool:
1212
+ """
1213
+ Pause a scheduled job in the AsyncIO scheduler.
1214
+
1215
+ This method pauses a job in the AsyncIOScheduler identified by its unique signature.
1216
+ It validates the provided signature to ensure it is a non-empty string and attempts
1217
+ to pause the job. If the operation is successful, it logs the action and returns True.
1218
+ If the job cannot be paused (e.g., it does not exist), the method returns False.
1219
+
1220
+ Parameters
1221
+ ----------
1222
+ signature : str
1223
+ The unique signature (ID) of the job to pause. This must be a non-empty string.
1224
+
1225
+ Returns
1226
+ -------
1227
+ bool
1228
+ True if the job was successfully paused.
1229
+ False if the job does not exist or an error occurred.
1230
+
1231
+ Raises
1232
+ ------
1233
+ CLIOrionisValueError
1234
+ If the `signature` parameter is not a non-empty string.
1235
+ """
1236
+
1237
+ # Validate that the signature is a non-empty string
1238
+ if not isinstance(signature, str) or not signature.strip():
1239
+ raise CLIOrionisValueError("Signature must be a non-empty string.")
1240
+
1050
1241
  try:
1051
1242
 
1052
- # Shut down the AsyncIOScheduler, waiting for jobs if specified.
1053
- if self.__scheduler.running:
1243
+ # Attempt to pause the job with the given signature
1244
+ self.__scheduler.pause_job(signature)
1245
+
1246
+ # Log the successful pausing of the job
1247
+ self.__logger.info(f"Job '{signature}' has been paused.")
1248
+ return True
1249
+
1250
+ except Exception:
1251
+
1252
+ # Return False if the job could not be paused (e.g., it does not exist)
1253
+ return False
1254
+
1255
+ def resume(self, signature: str) -> bool:
1256
+ """
1257
+ Resume a paused job in the AsyncIO scheduler.
1258
+
1259
+ This method attempts to resume a job that was previously paused in the AsyncIOScheduler.
1260
+ It validates the provided job signature, ensures it is a non-empty string, and then
1261
+ resumes the job if it exists and is currently paused. If the operation is successful,
1262
+ it logs the action and returns True. If the job cannot be resumed (e.g., it does not exist),
1263
+ the method returns False.
1264
+
1265
+ Parameters
1266
+ ----------
1267
+ signature : str
1268
+ The unique signature (ID) of the job to resume. This must be a non-empty string.
1269
+
1270
+ Returns
1271
+ -------
1272
+ bool
1273
+ True if the job was successfully resumed, False if the job does not exist or an error occurred.
1274
+
1275
+ Raises
1276
+ ------
1277
+ CLIOrionisValueError
1278
+ If the `signature` parameter is not a non-empty string.
1279
+ """
1280
+
1281
+ # Validate that the signature is a non-empty string
1282
+ if not isinstance(signature, str) or not signature.strip():
1283
+ raise CLIOrionisValueError("Signature must be a non-empty string.")
1054
1284
 
1055
- # For AsyncIOScheduler, shutdown can be called normally
1056
- # but we await any pending operations
1057
- self.__scheduler.shutdown(wait=wait)
1285
+ try:
1286
+ # Attempt to resume the job with the given signature
1287
+ self.__scheduler.resume_job(signature)
1058
1288
 
1059
- # Give a small delay to ensure proper cleanup
1060
- if wait:
1061
- await asyncio.sleep(0.1)
1289
+ # Log the successful resumption of the job
1290
+ self.__logger.info(f"Job '{signature}' has been resumed.")
1291
+ return True
1062
1292
 
1063
1293
  except Exception:
1064
1294
 
1065
- # AsyncIOScheduler may not be running or may have issues in shutdown
1066
- pass
1295
+ # Return False if the job could not be resumed (e.g., it does not exist)
1296
+ return False
1067
1297
 
1068
- async def remove(self, signature: str) -> bool:
1298
+ def remove(self, signature: str) -> bool:
1069
1299
  """
1070
- Remove a scheduled job from the AsyncIO scheduler asynchronously.
1300
+ Remove a scheduled job from the AsyncIO scheduler.
1071
1301
 
1072
- This method removes a job with the specified signature from both the internal
1073
- jobs dictionary and the AsyncIOScheduler instance. Using async ensures proper
1074
- cleanup in asyncio environments.
1302
+ This method removes a job from the AsyncIOScheduler using its unique signature (ID).
1303
+ It validates the provided signature to ensure it is a non-empty string, attempts to
1304
+ remove the job from the scheduler, and updates the internal jobs list accordingly.
1305
+ If the operation is successful, it logs the action and returns True. If the job
1306
+ cannot be removed (e.g., it does not exist), the method returns False.
1075
1307
 
1076
1308
  Parameters
1077
1309
  ----------
1078
1310
  signature : str
1079
- The signature of the command/job to remove from the scheduler.
1311
+ The unique signature (ID) of the job to remove. This must be a non-empty string.
1080
1312
 
1081
1313
  Returns
1082
1314
  -------
1083
1315
  bool
1084
- Returns True if the job was successfully removed, False if the job was not found.
1316
+ True if the job was successfully removed from the scheduler.
1317
+ False if the job does not exist or an error occurred.
1085
1318
 
1086
1319
  Raises
1087
1320
  ------
1088
- ValueError
1089
- If the signature is not a non-empty string.
1321
+ CLIOrionisValueError
1322
+ If the `signature` parameter is not a non-empty string.
1090
1323
  """
1091
1324
 
1092
1325
  # Validate that the signature is a non-empty string
1093
1326
  if not isinstance(signature, str) or not signature.strip():
1094
- raise ValueError("Signature must be a non-empty string.")
1327
+ raise CLIOrionisValueError("Signature must be a non-empty string.")
1095
1328
 
1096
1329
  try:
1097
1330
 
1098
- # Remove from the scheduler
1331
+ # Attempt to remove the job from the scheduler using its signature
1099
1332
  self.__scheduler.remove_job(signature)
1100
1333
 
1101
- # Remove from internal jobs dictionary
1102
- if signature in self.__jobs:
1103
- del self.__jobs[signature]
1334
+ # Iterate through the internal jobs list to find and remove the job
1335
+ for job in self.__jobs:
1336
+ if job['signature'] == signature:
1337
+ self.__jobs.remove(job) # Remove the job from the internal list
1338
+ break
1104
1339
 
1105
- # Give a small delay to ensure proper cleanup
1106
- await asyncio.sleep(0.01)
1107
-
1108
- # Log the removal of the job
1340
+ # Log the successful removal of the job
1109
1341
  self.__logger.info(f"Job '{signature}' has been removed from the scheduler.")
1110
-
1111
- # Return True to indicate successful removal
1112
1342
  return True
1113
1343
 
1114
1344
  except Exception:
1115
1345
 
1116
- # Job not found or other error
1346
+ # Return False if the job could not be removed (e.g., it does not exist)
1117
1347
  return False
1118
1348
 
1119
1349
  def events(self) -> list: