Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new utc baseline for (old) schedule PR, make some datetime objs tzaware #489

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
178 changes: 178 additions & 0 deletions examples/scheduler_check.py
Original file line number Diff line number Diff line change
@@ -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 ``<my_package>`` to create a log file for logging
``<my_package>`` class output. In this example we use a ``debug``
flag set in ``<my_package>`` 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)
6 changes: 3 additions & 3 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
docutils
mock
Pygments
pytest
pytest-cov
pytest-flake8
Sphinx
docutils
Pygments
black==20.8b1
mypy
mypy
46 changes: 23 additions & 23 deletions schedule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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"
)
Expand All @@ -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"
Expand All @@ -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):
"""
Expand All @@ -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):
Expand All @@ -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'")
Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions test_job_wrappers.py
Original file line number Diff line number Diff line change
@@ -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 == ''