queuerPy 0.1.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.
_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Version information for queuerPy package."""
2
+
3
+ __version__ = "0.1.0"
4
+ __version_info__ = tuple(int(part) for part in __version__.split("."))
5
+
6
+ # For compatibility
7
+ VERSION = __version__
queuer.py ADDED
@@ -0,0 +1,580 @@
1
+ """
2
+ Main Queuer class for Python implementation.
3
+ Mirrors the Go Queuer struct with Python async patterns.
4
+ """
5
+
6
+ # Standard library imports
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import threading
11
+ import time
12
+ import traceback
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional, Dict, Callable, Any
15
+ from uuid import UUID
16
+
17
+ from core.ticker import Ticker
18
+ from core.runner import Runner, SmallRunner, go_func
19
+ from database.db_listener import QueuerListener, new_queuer_db_listener
20
+ from helper.logging import get_logger
21
+ from helper.database import DatabaseConfiguration
22
+ from model.job import Job, JobStatus
23
+ from model.worker import Worker, new_worker, new_worker_with_options, WorkerStatus
24
+ from model.options_on_error import OnError
25
+ from queuer_job import QueuerJobMixin
26
+ from queuer_task import QueuerTaskMixin
27
+ from queuer_next_interval import QueuerNextIntervalMixin
28
+ from queuer_listener import QueuerListenerMixin
29
+
30
+ logger = get_logger(__name__)
31
+
32
+
33
+ def new_queuer(name: str, max_concurrency: int, *options: OnError) -> "Queuer":
34
+ """
35
+ Create a new Queuer instance with the given name and max concurrency.
36
+ The encryption key for the database is taken from environment variable QUEUER_ENCRYPTION_KEY.
37
+ """
38
+ encryption_key = os.getenv("QUEUER_ENCRYPTION_KEY", "")
39
+ first_option = options[0] if len(options) > 0 else None
40
+ return Queuer(name, max_concurrency, encryption_key, None, first_option)
41
+
42
+
43
+ def new_queuer_with_db(
44
+ name: str,
45
+ max_concurrency: int,
46
+ encryption_key: str = "",
47
+ db_config: Optional[DatabaseConfiguration] = None,
48
+ *options: OnError,
49
+ ) -> "Queuer":
50
+ """
51
+ Create a new Queuer instance with database configuration.
52
+ If dbConfig is None, uses environment variables for database connection.
53
+
54
+ If the encryption key is empty, it defaults to unencrypted results.
55
+
56
+ If any error occurs during initialization, it logs a panic error and exits the program.
57
+ It returns a pointer to the newly created Queuer instance.
58
+ """
59
+ first_option = options[0] if len(options) > 0 else None
60
+ return Queuer(name, max_concurrency, encryption_key, db_config, first_option)
61
+
62
+
63
+ class Queuer(
64
+ QueuerJobMixin,
65
+ QueuerTaskMixin,
66
+ QueuerNextIntervalMixin,
67
+ QueuerListenerMixin,
68
+ ):
69
+ """
70
+ Main queuing system class.
71
+ Mirrors the Go Queuer struct with Python async compatibility.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ name: str,
77
+ max_concurrency: int,
78
+ encryption_key: str = "",
79
+ db_config: Optional[DatabaseConfiguration] = None,
80
+ options: Optional[OnError] = None,
81
+ ):
82
+ """
83
+ __init__ is the inner initialization method that's being used for new_queuer and new_queuer_with_db.
84
+ It initializes the database connection and worker.
85
+ If options are provided, it creates a worker with those options.
86
+
87
+ It takes the db configuration from environment variables if dbConfig is nil.
88
+ - QUEUER_DB_HOST (required)
89
+ - QUEUER_DB_PORT (required)
90
+ - QUEUER_DB_DATABASE (required)
91
+ - QUEUER_DB_USERNAME (required)
92
+ - QUEUER_DB_PASSWORD (required)
93
+ - QUEUER_DB_SCHEMA (required)
94
+ - QUEUER_DB_SSLMODE (optional, defaults to "require")
95
+
96
+ If the encryption key is empty, it defaults to unencrypted results.
97
+
98
+ If any error occurs during initialization, it logs a panic error and exits the program.
99
+ It returns a pointer to the newly created Queuer instance.
100
+ """
101
+ super().__init__()
102
+
103
+ # Store name and concurrency for access
104
+ self.name: str = name
105
+ self.max_concurrency: int = max_concurrency
106
+
107
+ # Context management - will be set in start()
108
+ self._stopped: threading.Event = threading.Event()
109
+ self._cancel_event: Optional[asyncio.Event] = None
110
+
111
+ # Configuration
112
+ self.job_poll_interval: timedelta = timedelta(minutes=5)
113
+ self.retention_archive: timedelta = timedelta(days=30)
114
+
115
+ # Active runners (jobs currently executing)
116
+ self.active_runners: Dict[UUID, Runner] = {}
117
+
118
+ # Database listeners (will be initialized in start())
119
+ self.job_db_listener: Optional[QueuerListener] = None
120
+ self.job_archive_db_listener: Optional[QueuerListener] = None
121
+
122
+ # Database listener runners (track go_func runners for cleanup)
123
+ self.job_db_listener_runner: Optional[SmallRunner] = None
124
+ self.job_archive_db_listener_runner: Optional[SmallRunner] = None
125
+
126
+ # Database configuration
127
+ if db_config is not None:
128
+ self.db_config: DatabaseConfiguration = db_config
129
+ else:
130
+ self.db_config: DatabaseConfiguration = DatabaseConfiguration.from_env()
131
+
132
+ super().initialise(
133
+ db_config=self.db_config,
134
+ encryption_key=encryption_key,
135
+ )
136
+
137
+ # Create and insert worker
138
+ if options:
139
+ new_worker_obj: Worker = new_worker_with_options(
140
+ name, max_concurrency, options
141
+ )
142
+ else:
143
+ new_worker_obj: Worker = new_worker(name, max_concurrency)
144
+
145
+ try:
146
+ self.worker: Worker = self.db_worker.insert_worker(new_worker_obj)
147
+ self.worker_mutex: threading.RLock = threading.RLock()
148
+ except Exception as e:
149
+ logger.error(f"Error inserting worker into database: {e}")
150
+ raise RuntimeError(f"Error inserting worker into database: {e}")
151
+
152
+ logger.info(
153
+ f"Queuer with worker created: {new_worker_obj.name} (RID: {self.worker.rid})"
154
+ )
155
+
156
+ def start(self):
157
+ """Start the queuer."""
158
+ if self.running:
159
+ raise RuntimeError("Queuer is already running")
160
+
161
+ # No need to verify event loop with multiprocessing-based Runner
162
+
163
+ # Set up context
164
+ self.running = True
165
+ self._stopped.clear()
166
+ self._cancel_event = asyncio.Event()
167
+
168
+ # Set up database listeners
169
+ try:
170
+ self.job_db_listener = new_queuer_db_listener(self.db_config, "job")
171
+ logger.info("Added listener for channel: job")
172
+ self.job_archive_db_listener = new_queuer_db_listener(
173
+ self.db_config, "job_archive"
174
+ )
175
+ logger.info("Added listener for channel: job_archive")
176
+ except Exception as e:
177
+ logger.error(f"Error creating database listeners: {e}")
178
+ raise RuntimeError(f"Error creating database listeners: {e}")
179
+
180
+ # Ensure database is connected (reconnect if it was closed by a previous stop())
181
+ try:
182
+ if not self.database.instance:
183
+ logger.debug("Database connection was closed, reconnecting...")
184
+ self.database.connect_to_database()
185
+ except Exception as e:
186
+ logger.error(f"Error reconnecting to database: {e}")
187
+ raise RuntimeError(f"Error reconnecting to database: {e}")
188
+
189
+ # Update worker status to running
190
+ try:
191
+ with self.worker_mutex:
192
+ self.worker.status = WorkerStatus.RUNNING
193
+ worker_updated = self.db_worker.update_worker(self.worker)
194
+ if not worker_updated:
195
+ raise Exception("Could not update worker to RUNNING")
196
+
197
+ self.worker = worker_updated
198
+ except Exception as e:
199
+ logger.error(f"Error updating worker status to running: {e}")
200
+ raise RuntimeError(f"Error updating worker status to running: {e}")
201
+
202
+ # Start background tasks
203
+ self._start_listeners()
204
+ self._wait_for_listeners_ready()
205
+
206
+ self._start_heartbeat_ticker()
207
+ self._start_poll_job_ticker() # Backup polling every 5 minutes
208
+
209
+ logger.info("Queuer started")
210
+
211
+ def stop(self):
212
+ """Stop the queuer."""
213
+ # Store whether we were running to decide if we need to close connections
214
+ was_running = getattr(self, "_running", False)
215
+
216
+ # Stop tickers first to prevent new work from being queued
217
+ if self.heartbeat_ticker:
218
+ try:
219
+ self.heartbeat_ticker.stop()
220
+ except Exception as e:
221
+ logger.warning(f"Error stopping heartbeat ticker: {e}")
222
+
223
+ if self.poll_job_ticker:
224
+ try:
225
+ self.poll_job_ticker.stop()
226
+ except Exception as e:
227
+ logger.warning(f"Error stopping poll job ticker: {e}")
228
+
229
+ # Close database listeners
230
+ if self.job_db_listener:
231
+ try:
232
+ # Use go_func but wait for completion
233
+ task = go_func(self.job_db_listener.stop, use_mp=False)
234
+ task.get_results(timeout=3.0) # Wait up to 3 seconds total
235
+ except Exception as e:
236
+ # Only log if it's not already closed
237
+ if "closed" not in str(e).lower():
238
+ logger.warning(f"Error stopping job listener: {e}")
239
+
240
+ # Wait for database listener runners to complete
241
+ if self.job_db_listener_runner:
242
+ try:
243
+ self.job_db_listener_runner.get_results(timeout=2.0)
244
+ self.job_db_listener_runner = None
245
+ except Exception as e:
246
+ logger.warning(f"Error waiting for job listener runner: {e}")
247
+
248
+ if self.job_archive_db_listener:
249
+ try:
250
+ task = go_func(self.job_archive_db_listener.stop, use_mp=False)
251
+ task.get_results(timeout=3.0) # Wait up to 3 seconds total
252
+ except Exception as e:
253
+ if "closed" not in str(e).lower():
254
+ logger.warning(f"Error stopping job archive listener: {e}")
255
+
256
+ # Wait for database archive listener runner to complete
257
+ if self.job_archive_db_listener_runner:
258
+ try:
259
+ self.job_archive_db_listener_runner.get_results(timeout=2.0)
260
+ self.job_archive_db_listener_runner = None
261
+ except Exception as e:
262
+ logger.warning(f"Error waiting for job archive listener runner: {e}")
263
+
264
+ # Update worker status to stopped (only if we were running)
265
+ worker_rid = None
266
+ if was_running:
267
+ try:
268
+ with self.worker_mutex:
269
+ self.worker.status = WorkerStatus.STOPPED
270
+ worker_updated = self.db_worker.update_worker(self.worker)
271
+ if worker_updated:
272
+ worker_rid = self.worker.rid
273
+ except Exception as e:
274
+ logger.error(f"Error updating worker status to stopped: {e}")
275
+ else:
276
+ # If we were never running, just get the worker_rid for job cancellation
277
+ worker_rid = self.worker.rid
278
+
279
+ # Cancel all queued and running jobs
280
+ if worker_rid:
281
+ try:
282
+ self.cancel_all_jobs_by_worker(worker_rid, 100)
283
+ except Exception as e:
284
+ logger.error(f"Error cancelling all jobs by worker: {e}")
285
+
286
+ # Cancel the context equivalent - set cancellation event
287
+ if self._cancel_event:
288
+ try:
289
+ self._cancel_event.set()
290
+ except Exception as e:
291
+ logger.warning(f"Error setting cancel event: {e}")
292
+
293
+ # Signal that we're stopping
294
+ self.running = False
295
+ self._stopped.set()
296
+
297
+ # Cancel all active runners - similar to Go's job cancellation
298
+ active_runner_count = len(self.active_runners)
299
+ if active_runner_count > 0:
300
+ logger.info(f"Cancelling {active_runner_count} active runners")
301
+
302
+ for runner_id, runner in list(self.active_runners.items()):
303
+ try:
304
+ runner.cancel()
305
+ if runner_id in self.active_runners:
306
+ del self.active_runners[runner_id]
307
+ logger.debug(f"Removed runner {runner_id} from active_runners")
308
+
309
+ except Exception as e:
310
+ logger.warning(f"Error cancelling runner {runner_id}: {e}")
311
+
312
+ # Final check
313
+ remaining_runners = len(self.active_runners)
314
+ if remaining_runners > 0:
315
+ logger.warning(f"{remaining_runners} runners could not be cleaned up")
316
+ else:
317
+ logger.debug("All active runners cleaned up successfully")
318
+
319
+ # Cleanup broadcasters and listeners
320
+ try:
321
+ broadcaster_count = 0
322
+ if self.job_insert_broadcaster:
323
+ listener_count = len(self.job_insert_broadcaster.listeners)
324
+ if listener_count > 0:
325
+ logger.debug(
326
+ f"Clearing {listener_count} listeners from job_insert_broadcaster"
327
+ )
328
+ self.job_insert_broadcaster.listeners.clear()
329
+ broadcaster_count += 1
330
+
331
+ if self.job_update_broadcaster:
332
+ listener_count = len(self.job_update_broadcaster.listeners)
333
+ if listener_count > 0:
334
+ logger.debug(
335
+ f"Clearing {listener_count} listeners from job_update_broadcaster"
336
+ )
337
+ self.job_update_broadcaster.listeners.clear()
338
+ broadcaster_count += 1
339
+
340
+ if self.job_delete_broadcaster:
341
+ listener_count = len(self.job_delete_broadcaster.listeners)
342
+ if listener_count > 0:
343
+ logger.debug(
344
+ f"Clearing {listener_count} listeners from job_delete_broadcaster"
345
+ )
346
+ self.job_delete_broadcaster.listeners.clear()
347
+ broadcaster_count += 1
348
+
349
+ if broadcaster_count > 0:
350
+ logger.debug(f"Cleaned up {broadcaster_count} broadcasters")
351
+
352
+ except Exception as e:
353
+ logger.warning(f"Error during broadcaster cleanup: {e}")
354
+
355
+ # Cleanup AsyncIO resources
356
+ try:
357
+ try:
358
+ loop = asyncio.get_running_loop()
359
+ pending_tasks = [
360
+ task for task in asyncio.all_tasks(loop) if not task.done()
361
+ ]
362
+ if pending_tasks:
363
+ logger.debug(f"Cancelling {len(pending_tasks)} pending async tasks")
364
+ for task in pending_tasks:
365
+ task.cancel()
366
+ except RuntimeError:
367
+ pass
368
+
369
+ # Clear the cancel event
370
+ self._cancel_event = None
371
+
372
+ except Exception as e:
373
+ logger.warning(f"Error during AsyncIO cleanup: {e}")
374
+
375
+ if self.database:
376
+ try:
377
+ logger.info(f"Closing database '{self.name}'")
378
+ self.database.close()
379
+ except Exception as e:
380
+ logger.error(f"Error closing main database connection: {e}")
381
+
382
+ logger.info(f"Queuer '{self.worker.name}' stopped")
383
+
384
+ # Job notification listeners
385
+ async def _handle_job_notification(self, notification: str):
386
+ """
387
+ Handle job database notifications.
388
+
389
+ :param notification: The notification payload as a string
390
+ """
391
+ if not self.running:
392
+ logger.warning(f"Queuer not running, ignoring job notification")
393
+ return
394
+
395
+ if not (
396
+ hasattr(self, "job_insert_broadcaster") and self.job_insert_broadcaster
397
+ ):
398
+ logger.debug(f"[{self.name}] No job_insert_broadcaster available")
399
+ return
400
+
401
+ if not (
402
+ hasattr(self, "job_update_broadcaster") and self.job_update_broadcaster
403
+ ):
404
+ logger.debug(f"[{self.name}] No job_update_broadcaster available")
405
+ return
406
+
407
+ try:
408
+ job = Job.from_dict(json.loads(notification))
409
+
410
+ logger.debug(f"[{self.name}] Received job notification: {job}")
411
+
412
+ if job.status in [JobStatus.QUEUED, JobStatus.SCHEDULED]:
413
+ go_func(self._run_job_initial, use_mp=False)
414
+ await self.job_insert_broadcaster.broadcast(job)
415
+ else:
416
+ await self.job_update_broadcaster.broadcast(job)
417
+ except Exception as e:
418
+ logger.error(f"Error handling job notification: {e}")
419
+
420
+ async def _handle_job_archive_notification(self, notification: str):
421
+ """
422
+ Handle job archive database notifications.
423
+
424
+ :param notification: The notification payload as a string
425
+ """
426
+ try:
427
+ job_data = json.loads(notification)
428
+ job = Job.from_dict(job_data)
429
+
430
+ if job.status == "CANCELLED":
431
+ if job.rid in self.active_runners:
432
+ logger.info(f"Canceling running job: {job.rid}")
433
+ runner: Runner = self.active_runners[job.rid]
434
+ runner.cancel()
435
+ del self.active_runners[job.rid]
436
+ else:
437
+ logger.info(f"Job {job.rid} completed with status: {job.status}")
438
+ if (
439
+ hasattr(self, "job_delete_broadcaster")
440
+ and self.job_delete_broadcaster
441
+ ):
442
+ await self.job_delete_broadcaster.broadcast(job)
443
+ logger.info(f"Broadcasted job deletion completion for {job.rid}")
444
+
445
+ except Exception as e:
446
+ logger.error(f"Error handling job archive notification: {e}")
447
+ traceback.print_exc()
448
+
449
+ def _start_listeners(self):
450
+ """Start database listeners."""
451
+ # Start job listener
452
+ if self.job_db_listener:
453
+ try:
454
+ logger.debug("Starting job listeners")
455
+ job_runner = go_func(
456
+ self._start_job_listener,
457
+ False,
458
+ self._handle_job_notification,
459
+ )
460
+ if job_runner and isinstance(job_runner, SmallRunner):
461
+ self.job_db_listener_runner = job_runner
462
+ except Exception as e:
463
+ logger.error(f"Error starting job listener: {e}")
464
+
465
+ # Start job archive listener
466
+ if self.job_archive_db_listener:
467
+ try:
468
+ logger.debug("Starting job archive listeners")
469
+ archive_runner = go_func(
470
+ self._start_job_archive_listener,
471
+ False,
472
+ self._handle_job_archive_notification,
473
+ )
474
+ if archive_runner and isinstance(archive_runner, SmallRunner):
475
+ self.job_archive_db_listener_runner = archive_runner
476
+ except Exception as e:
477
+ logger.error(f"Error starting job archive listener: {e}")
478
+
479
+ def _wait_for_listeners_ready(self, timeout_seconds: float = 3.0):
480
+ """
481
+ Wait for both database listeners to be ready by checking their connection status.
482
+
483
+ :param timeout_seconds: Maximum time to wait for listeners to be ready
484
+ :raises RuntimeError: If listeners don't become ready within timeout
485
+ """
486
+ logger.debug("Waiting for database listeners to be ready...")
487
+ start_time = time.time()
488
+
489
+ while time.time() - start_time < timeout_seconds:
490
+ job_ready = (
491
+ self.job_db_listener
492
+ and hasattr(self.job_db_listener, "connection")
493
+ and self.job_db_listener.connection is not None
494
+ and hasattr(self.job_db_listener, "listening")
495
+ and self.job_db_listener.listening
496
+ )
497
+
498
+ archive_ready = (
499
+ self.job_archive_db_listener
500
+ and hasattr(self.job_archive_db_listener, "connection")
501
+ and self.job_archive_db_listener.connection is not None
502
+ and hasattr(self.job_archive_db_listener, "listening")
503
+ and self.job_archive_db_listener.listening
504
+ )
505
+
506
+ if job_ready and archive_ready:
507
+ logger.info("Database listeners are ready")
508
+ return
509
+
510
+ time.sleep(0.1) # Small delay between checks
511
+
512
+ raise RuntimeError(
513
+ f"Database listeners failed to become ready within {timeout_seconds} seconds"
514
+ )
515
+
516
+ # Tickers
517
+ def _heartbeat_func(self):
518
+ """Send periodic heartbeats - only updates database, not queuer state."""
519
+ try:
520
+ # Get current worker from database
521
+ current_worker = self.db_worker.select_worker(self.worker.rid)
522
+ if current_worker:
523
+ # Update timestamp and save to database
524
+ current_worker.updated_at = datetime.now()
525
+ self.db_worker.update_worker(current_worker)
526
+ logger.debug(
527
+ f"Updated worker heartbeat timestamp: {current_worker.updated_at}"
528
+ )
529
+ except Exception as e:
530
+ logger.error(f"Heartbeat error: {e}")
531
+
532
+ def _start_heartbeat_ticker(self):
533
+ """Start heartbeat ticker using threading."""
534
+ self.heartbeat_ticker = Ticker(
535
+ timedelta(seconds=30),
536
+ self._heartbeat_func,
537
+ use_mp=False,
538
+ )
539
+ self.heartbeat_ticker.go()
540
+
541
+ def _poll_jobs_func(self):
542
+ """Poll for jobs as backup to notification system."""
543
+ try:
544
+ logger.debug("Running backup job polling")
545
+ self._run_job_initial()
546
+ except Exception as e:
547
+ logger.warning(f"Backup job polling error: {e}")
548
+
549
+ def _start_poll_job_ticker(self):
550
+ """
551
+ Start job polling ticker as backup mechanism.
552
+ This provides a safety net in case notification-based processing fails.
553
+ """
554
+ self.poll_job_ticker = Ticker(
555
+ self.job_poll_interval,
556
+ self._poll_jobs_func,
557
+ use_mp=False,
558
+ )
559
+ self.poll_job_ticker.go()
560
+
561
+ # Database listeners
562
+ async def _start_job_listener(self, handle_job_notification: Callable[[str], Any]):
563
+ """
564
+ Start job database listener.
565
+
566
+ :param handle_job_notification: The callback to handle job notifications
567
+ """
568
+ if self.job_db_listener:
569
+ await self.job_db_listener.listen(handle_job_notification)
570
+
571
+ async def _start_job_archive_listener(
572
+ self, handle_job_archive_notification: Callable[[str], Any]
573
+ ):
574
+ """
575
+ Start job archive database listener.
576
+
577
+ :param handle_job_archive_notification: The callback to handle job archive notifications
578
+ """
579
+ if self.job_archive_db_listener:
580
+ await self.job_archive_db_listener.listen(handle_job_archive_notification)
queuerPy/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ QueuerPy - A Python implementation of the queuer system
3
+
4
+ A job queuing and processing system with PostgreSQL backend that provides:
5
+ - Asynchronous job processing
6
+ - Retry mechanisms with configurable policies
7
+ - Job scheduling with interval functions
8
+ - Worker management and monitoring
9
+ - Database-backed persistence
10
+ - Event-driven notifications
11
+ """
12
+
13
+ import sys
14
+ import os
15
+
16
+ # Add the package directory to Python path to enable direct imports
17
+ _package_dir = os.path.dirname(__file__)
18
+ if _package_dir not in sys.path:
19
+ sys.path.insert(0, _package_dir)
20
+
21
+ from _version import __version__
22
+
23
+ __author__ = "Simon Herrmann"
24
+ __email__ = "siherrmann@users.noreply.github.com"
25
+
26
+ # Core exports
27
+ from queuer import (
28
+ Queuer,
29
+ new_queuer,
30
+ new_queuer_with_db,
31
+ )
32
+
33
+ from helper.database import (
34
+ DatabaseConfiguration,
35
+ )
36
+
37
+ from model.job import (
38
+ Job,
39
+ JobStatus,
40
+ )
41
+
42
+ from model.worker import (
43
+ Worker,
44
+ WorkerStatus,
45
+ )
46
+
47
+ from model.options_on_error import (
48
+ OnError,
49
+ RetryBackoff,
50
+ )
51
+
52
+ from helper.error import (
53
+ QueuerError,
54
+ )
55
+
56
+ # Convenience imports for common use cases
57
+ __all__ = [
58
+ # Core classes
59
+ "Queuer",
60
+ "new_queuer",
61
+ "new_queuer_with_db",
62
+ # Configuration
63
+ "DatabaseConfiguration",
64
+ # Models
65
+ "Job",
66
+ "JobStatus",
67
+ "Worker",
68
+ "WorkerStatus",
69
+ "OnError",
70
+ "RetryBackoff",
71
+ # Exceptions
72
+ "QueuerError",
73
+ # Version info
74
+ "__version__",
75
+ "__author__",
76
+ "__email__",
77
+ ]
queuerPy/_version.py ADDED
@@ -0,0 +1,7 @@
1
+ """Version information for queuerPy package."""
2
+
3
+ __version__ = "0.1.0"
4
+ __version_info__ = tuple(int(part) for part in __version__.split("."))
5
+
6
+ # For compatibility
7
+ VERSION = __version__