diff --git a/AUTHORS.rst b/AUTHORS.rst index 05864c8b..08e4ae97 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -15,3 +15,6 @@ Thanks to all the wonderful folks who have contributed to schedule over the year - schnepp - grampajoe - gilbsgilbs +- ljanyst +- antwal + diff --git a/FAQ.rst b/FAQ.rst index 2712fbb9..b391b7ef 100644 --- a/FAQ.rst +++ b/FAQ.rst @@ -216,4 +216,52 @@ How can I pass arguments to the job function? print('Hello', name) schedule.every(2).seconds.do(greet, name='Alice') - schedule.every(4).seconds.do(greet, name='Bob') + schedule.every(4).seconds.do(greet, name='Bob') + +How to run a job with decorator? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from schedule import every, repeat + import schedule + import time + + @repeat(every(1).minutes) + def job_a(): + print("Called function job_a()") + + @repeat(every(15).seconds) + def job_b(): + print("Called function job_b()") + + while True: + schedule.run_pending() + time.sleep(1) + +How to run a job with string interval definitions? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import threading + from schedule import when + import schedule + import time + + def run_threaded(job_func): + job_thread = threading.Thread(target=job_func) + job_thread.start() + + def job_a(): + print("Called function job_a()") + + def job_b(): + print("Called function job_b()") + + schedule.when('every 15 seconds').do(run_threaded, job_a) + schedule.when('every 1 minute').do(run_threaded, job_b) + + while True: + schedule.run_pending() + time.sleep(1) diff --git a/README.rst b/README.rst index 693c60d1..07a0a000 100644 --- a/README.rst +++ b/README.rst @@ -44,10 +44,10 @@ Usage schedule.every(10).minutes.do(job) schedule.every().hour.do(job) - schedule.every().day.at("10:30").do(job) + schedule.every().day.at("10:30:00").do(job) schedule.every(5).to(10).minutes.do(job) schedule.every().monday.do(job) - schedule.every().wednesday.at("13:15").do(job) + schedule.every().wednesday.at("13:15:20").do(job) while True: schedule.run_pending() diff --git a/docs/api.rst b/docs/api.rst index 4add1b4c..62c7eda2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -16,6 +16,7 @@ Main Interface .. autodata:: jobs .. autofunction:: every +.. autofunction:: when .. autofunction:: run_pending .. autofunction:: run_all .. autofunction:: clear diff --git a/docs/index.rst b/docs/index.rst index 5507b731..a93ad41b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,9 +44,9 @@ Usage schedule.every(10).minutes.do(job) schedule.every().hour.do(job) - schedule.every().day.at("10:30").do(job) + schedule.every().day.at("10:30:00").do(job) schedule.every().monday.do(job) - schedule.every().wednesday.at("13:15").do(job) + schedule.every().wednesday.at("13:15:20").do(job) while True: schedule.run_pending() diff --git a/schedule/__init__.py b/schedule/__init__.py index 45a6f1d2..998fba3a 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -44,6 +44,14 @@ import random import time +try: + from datetime import timezone + utc = timezone.utc +except ImportError: + from schedule.timezone import UTC + utc = UTC() + + logger = logging.getLogger('schedule') @@ -127,6 +135,21 @@ def every(self, interval=1): job = Job(interval, self) return job + def when(self, interval_definition): + """ + Schedule a new periodic job. + + :param interval_definition: See :meth:`Job.when `. + :return: A partially configured :class:`Job ` + """ + job = Job(1, self) + try: + job.when(interval_definition) + except Exception: + self.cancel_job(job) + raise + return job + def _run_job(self, job): ret = job.run() if isinstance(ret, CancelJob) or ret is CancelJob: @@ -149,7 +172,7 @@ def idle_seconds(self): :return: Number of seconds until :meth:`next_run `. """ - return (self.next_run - datetime.datetime.now()).total_seconds() + return (self.next_run - datetime.datetime.now(utc)).total_seconds() class Job(object): @@ -181,6 +204,7 @@ def __init__(self, interval, scheduler=None): self.start_day = None # Specific day of the week to start on self.tags = set() # unique set of tags for the job self.scheduler = scheduler # scheduler to register with + self.directive_map = None # map of directive names to function calls def __lt__(self, other): """ @@ -191,7 +215,7 @@ def __lt__(self, other): def __repr__(self): def format_time(t): - return t.strftime('%Y-%m-%d %H:%M:%S') if t else '[never]' + return t.strftime('%Y-%m-%d %H:%M:%S %Z') if t else '[never]' timestats = '(last run: %s, next run: %s)' % ( format_time(self.last_run), format_time(self.next_run)) @@ -338,19 +362,25 @@ def at(self, time_str): Calling this is only valid for jobs scheduled to run every N day(s). - :param time_str: A string in `XX:YY` format. + :param time_str: A string in `HH:MM` or `HH:MM:SS` format. :return: The invoked job instance """ assert self.unit in ('days', 'hours') or self.start_day - hour, minute = time_str.split(':') + parts = time_str.split(':') + assert 1 < len(parts) < 4, '`XX:YY` or `XX:YY:ZZ` format expected' + if len(parts) == 2: + parts.append(0) + hour, minute, second = parts minute = int(minute) + second = int(second) if self.unit == 'days' or self.start_day: hour = int(hour) assert 0 <= hour <= 23 elif self.unit == 'hours': hour = 0 assert 0 <= minute <= 59 - self.at_time = datetime.time(hour, minute) + assert 0 <= second <= 59 + self.at_time = datetime.time(hour, minute, second) return self def to(self, latest): @@ -391,12 +421,100 @@ def do(self, job_func, *args, **kwargs): self.scheduler.jobs.append(self) return self + def _build_directive_map(self): + """ + Build a map of directive names to function calls for the parser + """ + if self.directive_map is not None: + return + + # a list of valid directives + directive_names = ['second', 'seconds', 'minute', 'minutes', 'hour', + 'hours', 'day', 'days', 'week', 'weeks', 'monday', + 'tuesday', 'wednesday', 'thursday', 'friday', + 'saturday', 'sunday', 'at', 'to'] + + # get an appropriate setter reference + def get_attr(obj, attr): + for obj in [obj] + obj.__class__.mro(): + if attr in obj.__dict__: + ret = obj.__dict__[attr] + if isinstance(ret, property): + return lambda x: ret.__get__(x, type(x)) + return ret + + # build the dictionary of properties + self.directive_map = {} + for d in directive_names: + self.directive_map[d] = get_attr(self, d) + + def when(self, interval_definition): + """ + Schedule a job according to an interval definition. The definition + is a string that is the same as a sequence of method calls, except + that dots and parentheses are replaced with spaces. For example: + `when('every monday at 17:51')`. + + :param interval_definition: the interval definition + :return: The invoked job instance + """ + directives = interval_definition.lower().split() + assert len(directives) >= 2, 'definition too short' + assert directives[0] == 'every', \ + 'the definition must start with "every"' + + # set up the interval if necessary + try: + interval = int(directives[1]) + self.interval = interval + assert len(directives) >= 3, "definition to short" + directives = directives[2:] + except ValueError: + directives = directives[1:] + + # parse the definition + self._build_directive_map() + directives.reverse() + while directives: + directive = directives.pop() + assert directive in self.directive_map, \ + 'unknown directive: '+directive + + args = [] + + # check the argument to "to" + if directive == 'to': + arg = directives.pop() + try: + arg = int(arg) + except ValueError: + assert False, 'the "to" directive expects an integer' + args.append(arg) + + # check the argument to "at" + elif directive == 'at': + arg = directives.pop() + arg_split = arg.split(':') + assert len(arg_split) == 2, \ + 'the "at" directive expects a string like "12:34"' + try: + int(arg_split[0]) + int(arg_split[1]) + except ValueError: + assert False, \ + 'the "at" directive expects a string like "12:34"' + args.append(arg) + + # call the setter function + self.directive_map[directive](self, *args) + return self + @property def should_run(self): """ :return: ``True`` if the job should be run now. """ - return datetime.datetime.now() >= self.next_run + return datetime.datetime.now(utc) >= self.next_run def run(self): """ @@ -406,7 +524,7 @@ def run(self): """ logger.info('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() return ret @@ -423,7 +541,7 @@ def _schedule_next_run(self): 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: assert self.unit == 'weeks' weekdays = ( @@ -454,7 +572,7 @@ def _schedule_next_run(self): # If we are running for the first time, make sure we run # at the specified time *today* (or *this hour*) as well if not self.last_run: - now = datetime.datetime.now() + now = datetime.datetime.now(utc) if (self.unit == 'days' and self.at_time > now.time() and self.interval == 1): self.next_run = self.next_run - datetime.timedelta(days=1) @@ -462,7 +580,7 @@ def _schedule_next_run(self): self.next_run = self.next_run - datetime.timedelta(hours=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 @@ -483,6 +601,13 @@ def every(interval=1): return default_scheduler.every(interval) +def when(interval_definition): + """Calls :meth:`when ` on the + :data:`default scheduler instance `. + """ + return default_scheduler.when(interval_definition) + + def run_pending(): """Calls :meth:`run_pending ` on the :data:`default scheduler instance `. @@ -523,3 +648,22 @@ def idle_seconds(): :data:`default scheduler instance `. """ return default_scheduler.idle_seconds + + +def repeat(job): + """Decorator for scheduled functions/methods. The decorated + functions/methods should not accept any arguments. + Usage: + >>> from schedule import every, repeat + >>> import schedule + >>> import time + >>> @repeat(every(10).minutes) + >>> def job(): + >>> print("I am a scheduled job") + >>> while True: + >>> schedule.run_pending() + >>> time.sleep(1)""" + def _inner_decorator(decorated_function): + job.do(decorated_function) + return decorated_function + return _inner_decorator diff --git a/schedule/timezone.py b/schedule/timezone.py new file mode 100644 index 00000000..1530e389 --- /dev/null +++ b/schedule/timezone.py @@ -0,0 +1,19 @@ +import datetime + + +class UTC(datetime.tzinfo): + """tzinfo derived concrete class named "UTC" with offset of 0""" + # can be changed to another timezone name/offset + def __init__(self): + self.__offset = datetime.timedelta(seconds=0) + self.__dst = datetime.timedelta(0) + self.__name = "UTC" + + def utcoffset(self, dt): + return self.__offset + + def dst(self, dt): + return self.__dst + + def tzname(self, dt): + return self.__name diff --git a/setup.py b/setup.py index f6ae20d4..00974f1a 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ from setuptools import setup -SCHEDULE_VERSION = '0.5.0' +SCHEDULE_VERSION = '0.5.1' SCHEDULE_DOWNLOAD_URL = ( 'https://github.com/dbader/schedule/tarball/' + SCHEDULE_VERSION ) diff --git a/test_schedule.py b/test_schedule.py index 139745ff..b0c8f30a 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -1,4 +1,5 @@ """Unit tests for schedule.py""" +import sys import datetime import functools import mock @@ -9,7 +10,14 @@ # pylint: disable-msg=R0201,C0111,E0102,R0904,R0901 import schedule -from schedule import every +from schedule import every, when, repeat + +try: + from datetime import timezone + utc = timezone.utc +except ImportError: + from schedule.timezone import UTC + utc = UTC() def make_mock_job(name=None): @@ -36,9 +44,10 @@ 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, self.day, - self.hour, self.minute) + self.hour, self.minute).replace(tzinfo=tz) + self.original_datetime = datetime.datetime datetime.datetime = MockDate @@ -64,6 +73,22 @@ def test_singular_time_units_match_plural_units(self): assert every().day.unit == every().days.unit assert every().week.unit == every().weeks.unit + def test_utc_is_normal(self): + fo = utc + self.assertIsInstance(fo, datetime.tzinfo) + dt = datetime.datetime.now() + self.assertEqual(fo.utcoffset(dt), datetime.timedelta(0)) + self.assertEqual(fo.tzname(dt), "UTC") + + def test_utc_dst_is_dt(self): + fo = utc + dt = datetime.datetime.now() + if sys.version_info > (3, 0, 0): + dst_arg = None + else: + dst_arg = datetime.timedelta(0) + self.assertEqual(fo.dst(dt), dst_arg) + def test_time_range(self): with mock_datetime(2014, 6, 28, 12, 0): mock_job = make_mock_job() @@ -91,6 +116,8 @@ def test_at_time(self): mock_job = make_mock_job() assert every().day.at('10:30').do(mock_job).next_run.hour == 10 assert every().day.at('10:30').do(mock_job).next_run.minute == 30 + assert every().day.at('10:30').do(mock_job).next_run.second == 0 + assert every().day.at('10:30:20').do(mock_job).next_run.second == 20 def test_at_time_hour(self): with mock_datetime(2010, 1, 6, 12, 20): @@ -121,6 +148,27 @@ def test_next_run_time(self): assert every().saturday.do(mock_job).next_run.day == 9 assert every().sunday.do(mock_job).next_run.day == 10 + def test_when(self): + mock_job = make_mock_job() + + invalid_definitions = ['', 'foo bar', 'every 2', 'every 2 foobar', + 'every 2 to foo days', 'every monday at foo', + 'every monday at foo:bar'] + valid_definitions = ['every 2 days', 'every 3 to 5 days', + 'every monday at 17:51'] + + for definition in invalid_definitions: + try: + when(definition).do(mock_job) + assert False, "Invalid definition should not be parsed" + except Exception: + pass + + for definition in valid_definitions: + when(definition).do(mock_job) + + when('every 2 days').when('every 2 days').do(mock_job) + def test_run_all(self): mock_job = make_mock_job() every().minute.do(mock_job) @@ -129,6 +177,23 @@ def test_run_all(self): schedule.run_all() assert mock_job.call_count == 3 + def test_run_all_with_decorator(self): + mock_job = make_mock_job() + + @repeat(every().minute) + def _job1(): + mock_job() + + @repeat(every().hour) + def _job2(): + mock_job() + + @repeat(every().day.at('11:00')) + def _job3(): + mock_job() + schedule.run_all() + assert mock_job.call_count == 3 + def test_job_func_args_are_passed_on(self): mock_job = make_mock_job() every().second.do(mock_job, 1, 2, 'three', foo=23, bar={}) @@ -258,7 +323,8 @@ 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) assert schedule.idle_seconds() == 60 * 60 def test_cancel_job(self): diff --git a/tox.ini b/tox.ini index 932d656b..43b26127 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,9 @@ envlist = py27, py36, docs 3.5 = py35, docs 3.6 = py36, docs +[flake8] +max-line-length = 90 + [testenv] deps = -rrequirements-dev.txt commands =