Skip to content

Commit 93bf21a

Browse files
committed
jobqueue.py: stability improvments
- Job.job_queue is now weakref.proxy reducing the risk of cyclic pointers preventing Job object from being deleted. - JobQueue._put(): raise if both next_t and job.interval are None - Don't put repeating job back to queue if user had disabled it was disabled during the time of execution. - New method: Job.is_removed() - promising a consistent API (instead of access to private member Job._remove) - Documentation fixes.
1 parent cbf93e1 commit 93bf21a

File tree

1 file changed

+74
-55
lines changed

1 file changed

+74
-55
lines changed

telegram/ext/jobqueue.py

Lines changed: 74 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import time
2323
import warnings
2424
import datetime
25+
import weakref
2526
from numbers import Number
2627
from threading import Thread, Lock, Event
2728
from queue import PriorityQueue, Empty
@@ -82,6 +83,8 @@ def put(self, job, next_t=None):
8283
"""
8384
warnings.warn("'JobQueue.put' is being deprecated, use 'JobQueue.one_time_job', "
8485
"'JobQueue.repeating_job' or 'JobQueue.daily_job' instead")
86+
if job.job_queue is None:
87+
job.job_queue = self
8588
self._put(job, next_t=next_t)
8689

8790
def _put(self, job, next_t=None, last_t=None):
@@ -92,37 +95,39 @@ def _put(self, job, next_t=None, last_t=None):
9295
next_t (Optional[int, float, datetime.timedelta, datetime.datetime, datetime.time]):
9396
Time in or at which the job should run for the first time. This parameter will be
9497
interpreted depending on its type.
95-
``int`` or ``float`` will be interpreted as "seconds from now" in which the job
96-
should run.
97-
``datetime.timedelta`` will be interpreted as "time from now" in which the job
98-
should run.
99-
``datetime.datetime`` will be interpreted as a specific date and time at which the
100-
job should run.
101-
``datetime.time`` will be interpreted as a specific time at which the job should
102-
run. This could be either today or, if the time has already passed, tomorrow.
98+
99+
* ``int`` or ``float`` will be interpreted as "seconds from now" in which the job
100+
should run.
101+
* ``datetime.timedelta`` will be interpreted as "time from now" in which the job
102+
should run.
103+
* ``datetime.datetime`` will be interpreted as a specific date and time at which
104+
the job should run.
105+
* ``datetime.time`` will be interpreted as a specific time of day at which the job
106+
should run. This could be either today or, if the time has already passed,
107+
tomorrow.
103108
last_t (Optional[float]): Timestamp of the time when ``job`` was scheduled for in the
104-
last ``put`` call. If provided, it will used to calculate the next timestamp more
105-
accurately by accounting for the execution time of the job (and possibly others).
106-
By default, the current timestamp is used.
107-
"""
108-
if job.job_queue is not self:
109-
job.job_queue = self
109+
last ``put`` call. If provided, it will be used to calculate the next timestamp
110+
more accurately by accounting for the execution time of the job (and possibly
111+
others). If None, `now` will be assumed.
110112
113+
"""
111114
if next_t is None:
112115
next_t = job.interval
116+
if next_t is None:
117+
raise ValueError('next_t is None')
113118

114119
if isinstance(next_t, datetime.datetime):
115-
next_t = next_t - datetime.datetime.now()
120+
next_t = (next_t - datetime.datetime.now()).total_seconds()
116121

117122
elif isinstance(next_t, datetime.time):
118123
next_datetime = datetime.datetime.combine(datetime.date.today(), next_t)
119124

120125
if datetime.datetime.now().time() > next_t:
121126
next_datetime += datetime.timedelta(days=1)
122127

123-
next_t = next_datetime - datetime.datetime.now()
128+
next_t = (next_datetime - datetime.datetime.now()).total_seconds()
124129

125-
if isinstance(next_t, datetime.timedelta):
130+
elif isinstance(next_t, datetime.timedelta):
126131
next_t = next_t.total_seconds()
127132

128133
next_t += last_t or time.time()
@@ -145,22 +150,26 @@ def run_once(self, callback, when, context=None, name=None):
145150
when (int, float, datetime.timedelta, datetime.datetime, datetime.time):
146151
Time in or at which the job should run. This parameter will be interpreted
147152
depending on its type.
148-
``int`` or ``float`` will be interpreted as "seconds from now" in which the job
149-
should run.
150-
``datetime.timedelta`` will be interpreted as "time from now" in which the job
151-
should run.
152-
``datetime.datetime`` will be interpreted as a specific date and time at which the
153-
job should run.
154-
``datetime.time`` will be interpreted as a specific time at which the job should
155-
run. This could be either today or, if the time has already passed, tomorrow.
153+
154+
* ``int`` or ``float`` will be interpreted as "seconds from now" in which the job
155+
should run.
156+
* ``datetime.timedelta`` will be interpreted as "time from now" in which the job
157+
should run.
158+
* ``datetime.datetime`` will be interpreted as a specific date and time at which
159+
the job should run.
160+
* ``datetime.time`` will be interpreted as a specific time of day at which the job
161+
should run. This could be either today or, if the time has already passed,
162+
tomorrow.
163+
156164
context (Optional[object]): Additional data needed for the callback function. Can be
157165
accessed through ``job.context`` in the callback. Defaults to ``None``
158166
name (Optional[str]): The name of the new job. Defaults to ``callback.__name__``
159167
160168
Returns:
161169
Job: The new ``Job`` instance that has been added to the job queue.
170+
162171
"""
163-
job = Job(callback, repeat=False, context=context, name=name)
172+
job = Job(callback, repeat=False, context=context, name=name, job_queue=self)
164173
self._put(job, next_t=when)
165174
return job
166175

@@ -175,25 +184,32 @@ def run_repeating(self, callback, interval, first=None, context=None, name=None)
175184
interval (int, float, datetime.timedelta): The interval in which the job will run.
176185
If it is an ``int`` or a ``float``, it will be interpreted as seconds.
177186
first (int, float, datetime.timedelta, datetime.datetime, datetime.time):
178-
Time in or at which the job should run for the first time. This parameter will be
179-
interpreted depending on its type.
180-
``int`` or ``float`` will be interpreted as "seconds from now" in which the job
181-
should run.
182-
``datetime.timedelta`` will be interpreted as "time from now" in which the job
183-
should run.
184-
``datetime.datetime`` will be interpreted as a specific date and time at which the
185-
job should run.
186-
``datetime.time`` will be interpreted as a specific time at which the job should
187-
run. This could be either today or, if the time has already passed, tomorrow.
187+
188+
* ``int`` or ``float`` will be interpreted as "seconds from now" in which the job
189+
should run.
190+
* ``datetime.timedelta`` will be interpreted as "time from now" in which the job
191+
should run.
192+
* ``datetime.datetime`` will be interpreted as a specific date and time at which
193+
the job should run.
194+
* ``datetime.time`` will be interpreted as a specific time of day at which the job
195+
should run. This could be either today or, if the time has already passed,
196+
tomorrow.
197+
188198
Defaults to ``interval``
189199
context (Optional[object]): Additional data needed for the callback function. Can be
190200
accessed through ``job.context`` in the callback. Defaults to ``None``
191201
name (Optional[str]): The name of the new job. Defaults to ``callback.__name__``
192202
193203
Returns:
194204
Job: The new ``Job`` instance that has been added to the job queue.
205+
195206
"""
196-
job = Job(callback, interval=interval, repeat=True, context=context, name=name)
207+
job = Job(callback,
208+
interval=interval,
209+
repeat=True,
210+
context=context,
211+
name=name,
212+
job_queue=self)
197213
self._put(job, next_t=first)
198214
return job
199215

@@ -213,13 +229,15 @@ def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None
213229
214230
Returns:
215231
Job: The new ``Job`` instance that has been added to the job queue.
232+
216233
"""
217234
job = Job(callback,
218235
interval=datetime.timedelta(days=1),
219236
repeat=True,
220237
days=days,
221238
context=context,
222-
name=name)
239+
name=name,
240+
job_queue=self)
223241
self._put(job, next_t=time)
224242
return job
225243

@@ -245,7 +263,6 @@ def tick(self):
245263
while True:
246264
try:
247265
t, job = self.queue.get(False)
248-
249266
except Empty:
250267
break
251268

@@ -262,9 +279,8 @@ def tick(self):
262279
self._set_next_peek(t)
263280
break
264281

265-
if job._remove.is_set():
282+
if job.is_removed():
266283
self.logger.debug('Removing job %s', job.name)
267-
job.job_queue = None
268284
continue
269285

270286
if job.enabled:
@@ -277,16 +293,13 @@ def tick(self):
277293
except:
278294
self.logger.exception('An uncaught error was raised while executing job %s',
279295
job.name)
280-
281296
else:
282297
self.logger.debug('Skipping disabled job %s', job.name)
283298

284-
if job.repeat:
299+
if job.repeat and not job.is_removed():
285300
self._put(job, last_t=t)
286-
287301
else:
288-
self.logger.debug('Dropping non-repeating job %s', job.name)
289-
job.job_queue = None
302+
self.logger.debug('Dropping non-repeating or removed job %s', job.name)
290303

291304
def start(self):
292305
"""
@@ -301,7 +314,6 @@ def start(self):
301314
self.__thread = Thread(target=self._main_loop, name="job_queue")
302315
self.__thread.start()
303316
self.logger.debug('%s thread started', self.__class__.__name__)
304-
305317
else:
306318
self.__start_lock.release()
307319

@@ -314,7 +326,7 @@ def _main_loop(self):
314326
while self._running:
315327
# self._next_peek may be (re)scheduled during self.tick() or self.put()
316328
with self.__next_peek_lock:
317-
tmout = self._next_peek and self._next_peek - time.time()
329+
tmout = self._next_peek - time.time() if self._next_peek else None
318330
self._next_peek = None
319331
self.__tick.clear()
320332

@@ -372,6 +384,9 @@ class Job(object):
372384
days (Optional[tuple[int]]): Defines on which days of the week the job should run.
373385
Defaults to ``Days.EVERY_DAY``
374386
name (Optional[str]): The name of this job. Defaults to ``callback.__name__``
387+
job_queue (Optional[class:`telegram.ext.JobQueue`]): The ``JobQueue`` this job belongs to.
388+
Only optional for backward compatibility with ``JobQueue.put()``.
389+
375390
"""
376391

377392
def __init__(self,
@@ -380,7 +395,8 @@ def __init__(self,
380395
repeat=True,
381396
context=None,
382397
days=Days.EVERY_DAY,
383-
name=None):
398+
name=None,
399+
job_queue=None):
384400

385401
self.callback = callback
386402
self.context = context
@@ -394,7 +410,7 @@ def __init__(self,
394410
self._days = None
395411
self.days = days
396412

397-
self._job_queue = None
413+
self._job_queue = weakref.proxy(job_queue) if job_queue is not None else None
398414

399415
self._remove = Event()
400416
self._enabled = Event()
@@ -411,6 +427,9 @@ def schedule_removal(self):
411427
"""
412428
self._remove.set()
413429

430+
def is_removed(self):
431+
return self._remove.is_set()
432+
414433
@property
415434
def enabled(self):
416435
return self._enabled.is_set()
@@ -431,8 +450,7 @@ def interval(self, interval):
431450
if interval is None and self.repeat:
432451
raise ValueError("The 'interval' can not be 'None' when 'repeat' is set to 'True'")
433452

434-
if not (interval is None or isinstance(interval, Number) or
435-
isinstance(interval, datetime.timedelta)):
453+
if not (interval is None or isinstance(interval, (Number, datetime.timedelta))):
436454
raise ValueError("The 'interval' must be of type 'datetime.timedelta',"
437455
" 'int' or 'float'")
438456

@@ -480,10 +498,11 @@ def job_queue(self):
480498

481499
@job_queue.setter
482500
def job_queue(self, job_queue):
483-
if not self._job_queue or not job_queue:
484-
self._job_queue = job_queue
501+
# Property setter for backward compatibility with JobQueue.put()
502+
if not self._job_queue:
503+
self._job_queue = weakref.proxy(job_queue)
485504
else:
486-
raise ValueError("The 'job_queue' attribute can only be set once.")
505+
raise RuntimeError("The 'job_queue' attribute can only be set once.")
487506

488507
def __lt__(self, other):
489508
return False

0 commit comments

Comments
 (0)