diff --git a/examples/scheduler_check.py b/examples/scheduler_check.py new file mode 100644 index 00000000..bcca5583 --- /dev/null +++ b/examples/scheduler_check.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 + +import sys +import time +import datetime +import logging +import functools +import schedule + + +logger = logging.getLogger(__name__) + +def setup_logging(debug=False, filename=sys.stderr, fmt=None): + """ + Can be imported by ```` to create a log file for logging + ```` class output. In this example we use a ``debug`` + flag set in ```` to change the Log Level and ``filename`` + to set log path. We also use UTC time and force the name in ``datefmt``. + """ + if debug: + log_level = logging.getLevelName('DEBUG') + else: + log_level = logging.getLevelName('INFO') + + # process format: + # '%(asctime)s %(name)s[%(process)d] %(levelname)s - %(message)s' + # alt format + # '%(asctime)s %(levelname)s %(filename)s(%(lineno)d) %(message)s' + # long format + # '%(asctime)s %(name)s.%(funcName)s +%(lineno)s: %(levelname)s [%(process)d] %(message)s' + format = '%(asctime)s %(name)s.%(funcName)s +%(lineno)s: %(levelname)s [%(process)d] %(message)s' + + if not fmt: + fmt = format + + logging.basicConfig(level=log_level, + format=fmt, + datefmt='%Y-%m-%d %H:%M:%S UTC', + filename=filename) + + # BUG: This does not print the TZ name because logging module uses + # time instead of tz-aware datetime objects (so we force the + # correct name in datefmt above). + logging.Formatter.converter = time.gmtime + + # To also log parent info, try something like this + # global logger + # logger = logging.getLogger("my_package") + + +def show_job_tags(): + def show_job_tags_decorator(job_func): + """ + decorator to show job name and tags for current job + """ + import schedule + + @functools.wraps(job_func) + def job_tags(*args, **kwargs): + current_job = min(job for job in schedule.jobs) + job_tags = current_job.tags + logger.info('JOB: {}'.format(current_job)) + logger.info('TAGS: {}'.format(job_tags)) + return job_func(*args, **kwargs) + return job_tags + return show_job_tags_decorator + + +def run_until_success(max_retry=2): + """ + decorator for running a single job until success with retry limit + * will unschedule itself on success + * will reschedule on failure until max retry is exceeded + :requirements: + * the job function must return something to indicate success/failure + or raise an exception on non-success + :param max_retry: max number of times to reschedule the job on failure, + balance this with the job interval for best results + """ + import schedule + + def run_until_success_decorator(job_func): + @functools.wraps(job_func) + def wrapper(*args, **kwargs): + current = min(job for job in schedule.jobs) + num_try = int(max((tag for tag in current.tags if tag.isdigit()), default=0)) + tries_left = max_retry - num_try + next_try = num_try + 1 + + try: + result = job_func(*args, **kwargs) + + except Exception as exc: + # import traceback + import warnings + result = None + logger.debug('JOB: {} failed on try number: {}'.format(current, num_try)) + warnings.warn('{}'.format(exc), RuntimeWarning, stacklevel=2) + # logger.error('JOB: exception is: {}'.format(exc)) + # logger.error(traceback.format_exc()) + + finally: + if result: + logger.debug('JOB: {} claims success: {}'.format(current, result)) + return schedule.CancelJob + elif tries_left == 0: + logger.warning('JOB: {} failed with result: {}'.format(current, result)) + return schedule.CancelJob + else: + logger.debug('JOB: {} failed with {} try(s) left, trying again'.format(current, tries_left)) + current.tags.update(str(next_try)) + return result + + return wrapper + return run_until_success_decorator + + +def catch_exceptions(cancel_on_failure=False): + """ + decorator for running a suspect job with cancel_on_failure option + """ + import schedule + + def catch_exceptions_decorator(job_func): + @functools.wraps(job_func) + def wrapper(*args, **kwargs): + try: + return job_func(*args, **kwargs) + except: + import traceback + logger.debug(traceback.format_exc()) + if cancel_on_failure: + return schedule.CancelJob + return wrapper + return catch_exceptions_decorator + + +@show_job_tags() +@run_until_success() +def good_task(): + print('I am good') + return True + + +@run_until_success() +def good_returns(): + print("I'm good too") + return True, 'Success', 0 + + +@run_until_success() +def bad_returns(): + print("I'm not so good") + return 0 + + +@show_job_tags() +@catch_exceptions(cancel_on_failure=True) +def bad_task(): + print('I am bad') + raise Exception('Something went wrong!') + + +setup_logging(debug=True, filename='scheduler_check.log', fmt=None) + +schedule.every(3).seconds.do(good_task).tag('1') +schedule.every(5).seconds.do(good_task).tag('good') +schedule.every(8).seconds.do(bad_task).tag('bad') +schedule.every(3).seconds.do(good_returns) +schedule.every(5).seconds.do(bad_returns) + +have_jobs = len(schedule.jobs) +print(f'We have {have_jobs} jobs!') + +while have_jobs > 0: + schedule.run_pending() + time.sleep(1) + have_jobs = len(schedule.jobs) diff --git a/requirements-dev.txt b/requirements-dev.txt index 063560b4..ad3e9fb9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,9 @@ -docutils mock -Pygments pytest pytest-cov pytest-flake8 Sphinx +docutils +Pygments black==20.8b1 -mypy \ No newline at end of file +mypy diff --git a/schedule/__init__.py b/schedule/__init__.py index 83a95815..e77b51de 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -44,36 +44,30 @@ import random import re import time +from datetime import timezone from typing import Set, List, Optional, Callable, Union +utc = timezone.utc logger = logging.getLogger("schedule") class ScheduleError(Exception): """Base schedule exception""" - pass - class ScheduleValueError(ScheduleError): """Base schedule value error""" - pass - class IntervalError(ScheduleValueError): """An improper interval was used""" - pass - class CancelJob(object): """ Can be returned from a job to unschedule itself. """ - pass - class Scheduler(object): """ @@ -194,7 +188,7 @@ def idle_seconds(self) -> Optional[float]: """ if not self.next_run: return None - return (self.next_run - datetime.datetime.now()).total_seconds() + return (self.next_run - datetime.datetime.now(utc)).total_seconds() class Job(object): @@ -520,7 +514,7 @@ def at(self, time_str): ) elif self.unit == "hours": hour = 0 - elif self.unit == "minutes": + elif self.unit == "minutes": # pragma: no cover => probable unreachable branch hour = 0 minute = 0 minute = int(minute) @@ -573,17 +567,20 @@ def until( """ if isinstance(until_time, datetime.datetime): - self.cancel_after = until_time + self.cancel_after = until_time.replace(tzinfo=utc) elif isinstance(until_time, datetime.timedelta): - self.cancel_after = datetime.datetime.now() + until_time + self.cancel_after = datetime.datetime.now(utc) + until_time elif isinstance(until_time, datetime.time): self.cancel_after = datetime.datetime.combine( - datetime.datetime.now(), until_time + datetime.datetime.now(), until_time, tzinfo=utc ) elif isinstance(until_time, str): cancel_after = self._decode_datetimestr( until_time, [ + "%Y-%m-%d %H:%M:%S+00:00", + "%Y-%m-%d %H:%M+00:00", + "%Y-%m-%d+00:00", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", @@ -599,13 +596,13 @@ def until( cancel_after = cancel_after.replace( year=now.year, month=now.month, day=now.day ) - self.cancel_after = cancel_after + self.cancel_after = cancel_after.replace(tzinfo=utc) else: raise TypeError( "until() takes a string, datetime.datetime, datetime.timedelta, " "datetime.time parameter" ) - if self.cancel_after < datetime.datetime.now(): + if self.cancel_after < datetime.datetime.now(utc): raise ScheduleValueError( "Cannot schedule a job to run until a time in the past" ) @@ -625,7 +622,7 @@ def do(self, job_func: Callable, *args, **kwargs): self.job_func = functools.partial(job_func, *args, **kwargs) functools.update_wrapper(self.job_func, job_func) self._schedule_next_run() - if self.scheduler is None: + if self.scheduler is None: # pragma: no cover => probable unreachable branch raise ScheduleError( "Unable to a add job to schedule. " "Job is not associated with an scheduler" @@ -638,8 +635,11 @@ def should_run(self) -> bool: """ :return: ``True`` if the job should be run now. """ - assert self.next_run is not None, "must run _schedule_next_run before" - return datetime.datetime.now() >= self.next_run + if self.next_run is None: + raise ScheduleError( + "Must run _schedule_next_run before calling should_run!" + ) + return datetime.datetime.now(utc) >= self.next_run def run(self): """ @@ -653,13 +653,13 @@ def run(self): deadline is reached. """ - if self._is_overdue(datetime.datetime.now()): + if self._is_overdue(datetime.datetime.now(utc)): logger.debug("Cancelling job %s", self) return CancelJob logger.debug("Running job %s", self) ret = self.job_func() - self.last_run = datetime.datetime.now() + self.last_run = datetime.datetime.now(utc) self._schedule_next_run() if self._is_overdue(self.next_run): @@ -685,7 +685,7 @@ def _schedule_next_run(self) -> None: interval = self.interval self.period = datetime.timedelta(**{self.unit: interval}) - self.next_run = datetime.datetime.now() + self.period + self.next_run = datetime.datetime.now(utc) + self.period if self.start_day is not None: if self.unit != "weeks": raise ScheduleValueError("`unit` should be 'weeks'") @@ -720,7 +720,7 @@ def _schedule_next_run(self) -> None: # as well. This accounts for when a job takes so long it finished # in the next period. if not self.last_run or (self.next_run - self.last_run) > self.period: - now = datetime.datetime.now() + now = datetime.datetime.now(utc) if ( self.unit == "days" and self.at_time > now.time() @@ -739,7 +739,7 @@ def _schedule_next_run(self) -> None: self.next_run = self.next_run - datetime.timedelta(minutes=1) if self.start_day is not None and self.at_time is not None: # Let's see if we will still make that time we specified today - if (self.next_run - datetime.datetime.now()).days >= 7: + if (self.next_run - datetime.datetime.now(utc)).days >= 7: self.next_run -= self.period def _is_overdue(self, when: datetime.datetime): diff --git a/test_job_wrappers.py b/test_job_wrappers.py new file mode 100644 index 00000000..cc9ad4dd --- /dev/null +++ b/test_job_wrappers.py @@ -0,0 +1,8 @@ +import pytest + +def test_job_wrappers(script_runner): + ret = script_runner.run('examples/scheduler_check.py') + assert ret.success + assert 'good' in ret.stdout + assert 'bad' in ret.stdout + assert ret.stderr == '' diff --git a/test_schedule.py b/test_schedule.py index 5ed97ea8..26c3df1c 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -4,6 +4,8 @@ import mock import unittest +from datetime import timezone + # Silence "missing docstring", "method could be a function", # "class already defined", and "too many public methods" messages: # pylint: disable-msg=R0201,C0111,E0102,R0904,R0901 @@ -17,6 +19,8 @@ IntervalError, ) +utc = timezone.utc + def make_mock_job(name=None): job = mock.Mock() @@ -44,7 +48,7 @@ def today(cls): return cls(self.year, self.month, self.day) @classmethod - def now(cls): + def now(cls, tz=None): return cls( self.year, self.month, @@ -52,7 +56,7 @@ def now(cls): self.hour, self.minute, self.second, - ) + ).replace(tzinfo=tz) self.original_datetime = datetime.datetime datetime.datetime = MockDate @@ -262,6 +266,8 @@ def test_at_time(self): every(interval=2).saturday with self.assertRaises(IntervalError): every(interval=2).sunday + with self.assertRaises(ScheduleError): + every(interval=2).should_run def test_until_time(self): mock_job = make_mock_job() @@ -269,43 +275,49 @@ def test_until_time(self): with mock_datetime(2020, 1, 1, 10, 0, 0) as m: assert every().day.until(datetime.datetime(3000, 1, 1, 20, 30)).do( mock_job - ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 0) + ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 0, tzinfo=utc) assert every().day.until(datetime.datetime(3000, 1, 1, 20, 30, 50)).do( mock_job - ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 50) + ).cancel_after == datetime.datetime(3000, 1, 1, 20, 30, 50, tzinfo=utc) assert every().day.until(datetime.time(12, 30)).do( mock_job - ).cancel_after == m.replace(hour=12, minute=30, second=0, microsecond=0) + ).cancel_after == m.replace( + hour=12, minute=30, second=0, microsecond=0, tzinfo=utc + ) assert every().day.until(datetime.time(12, 30, 50)).do( mock_job - ).cancel_after == m.replace(hour=12, minute=30, second=50, microsecond=0) + ).cancel_after == m.replace( + hour=12, minute=30, second=50, microsecond=0, tzinfo=utc + ) assert every().day.until( datetime.timedelta(days=40, hours=5, minutes=12, seconds=42) - ).do(mock_job).cancel_after == datetime.datetime(2020, 2, 10, 15, 12, 42) + ).do(mock_job).cancel_after == datetime.datetime( + 2020, 2, 10, 15, 12, 42, tzinfo=utc + ) assert every().day.until("10:30").do(mock_job).cancel_after == m.replace( - hour=10, minute=30, second=0, microsecond=0 + hour=10, minute=30, second=0, microsecond=0, tzinfo=utc ) assert every().day.until("10:30:50").do(mock_job).cancel_after == m.replace( - hour=10, minute=30, second=50, microsecond=0 + hour=10, minute=30, second=50, microsecond=0, tzinfo=utc ) assert every().day.until("3000-01-01 10:30").do( mock_job - ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 0) + ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 0, tzinfo=utc) assert every().day.until("3000-01-01 10:30:50").do( mock_job - ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50) + ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50, tzinfo=utc) assert every().day.until(datetime.datetime(3000, 1, 1, 10, 30, 50)).do( mock_job - ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50) + ).cancel_after == datetime.datetime(3000, 1, 1, 10, 30, 50, tzinfo=utc) # Invalid argument types self.assertRaises(TypeError, every().day.until, 123) self.assertRaises(ScheduleValueError, every().day.until, "123") self.assertRaises(ScheduleValueError, every().day.until, "01-01-3000") - # Using .until() with moments in the passed + # Using .until() with moments in the past self.assertRaises( ScheduleValueError, every().day.until, @@ -314,7 +326,7 @@ def test_until_time(self): self.assertRaises( ScheduleValueError, every().day.until, datetime.timedelta(minutes=-1) ) - self.assertRaises(ScheduleValueError, every().day.until, datetime.time(hour=5)) + self.assertRaises(ScheduleValueError, every().day.until, datetime.time(0, 1, 0)) # Unschedule job after next_run passes the deadline schedule.clear() @@ -340,7 +352,7 @@ def test_until_time(self): assert mock_job.call_count == 0 assert len(schedule.jobs) == 0 - def test_weekday_at_todady(self): + def test_weekday_at_today(self): mock_job = make_mock_job() # This date is a wednesday @@ -404,6 +416,11 @@ def test_at_time_minute(self): assert every().minute.at(":10").do(mock_job).next_run.hour == 12 assert every().minute.at(":10").do(mock_job).next_run.minute == 21 assert every().minute.at(":10").do(mock_job).next_run.second == 10 + # test for missing branch BUT it appears to be unreachable: 517->520 + assert every().minute.at(":55").do(mock_job).unit == "minutes" + assert every().minute.at(":55").do(mock_job).at_time == datetime.time( + 0, 0, 55 + ) self.assertRaises(ScheduleValueError, every().minute.at, "::2") self.assertRaises(ScheduleValueError, every().minute.at, ".2") @@ -711,7 +728,9 @@ def test_next_run_property(self): every().hour.do(hourly_job) assert len(schedule.jobs) == 2 # Make sure the hourly job is first - assert schedule.next_run() == original_datetime(2010, 1, 6, 14, 16) + assert schedule.next_run() == original_datetime( + 2010, 1, 6, 14, 16, tzinfo=utc + ) def test_idle_seconds(self): assert schedule.next_run() is None diff --git a/tox.ini b/tox.ini index 584d93e1..231992da 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = py3{6,7,8,9} skip_missing_interpreters = true - +skipsdist = True [gh-actions] python = @@ -11,21 +11,33 @@ python = 3.9: py39 [testenv] +skip_install = true deps = -rrequirements-dev.txt commands = - py.test test_schedule.py schedule -v --cov schedule --cov-report term-missing + pytest test_schedule.py schedule -v --cov schedule --cov-report term-missing python -m mypy -p schedule [testenv:docs] +skip_install = true changedir = docs deps = -rrequirements-dev.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html [testenv:format] +skip_install = true deps = -rrequirements-dev.txt commands = black --check . [testenv:setuppy] deps = -rrequirements-dev.txt commands = - python setup.py check --strict --metadata --restructuredtext \ No newline at end of file + python setup.py check --strict --metadata --restructuredtext + +[testenv:check] +skip_install = true +setenv = PYTHONPATH = {toxinidir} +deps = + pytest + pytest-console-scripts + +commands = pytest -v --capture=no --script-launch-mode=subprocess test_job_wrappers.py