From 101be988544f6c5fbcfbaa6bac5c8d806a068a6c Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Fri, 13 Mar 2020 01:18:28 +0300 Subject: [PATCH 01/26] Added asynchronous scheduler and simple test to it --- requirements-dev.txt | 1 + schedule/__init__.py | 521 +----------------------------------- schedule/async_job.py | 13 + schedule/async_scheduler.py | 24 ++ schedule/job.py | 372 +++++++++++++++++++++++++ schedule/scheduler.py | 116 ++++++++ setup.py | 1 + test_async_scheduler.py | 46 ++++ 8 files changed, 578 insertions(+), 516 deletions(-) create mode 100644 schedule/async_job.py create mode 100644 schedule/async_scheduler.py create mode 100644 schedule/job.py create mode 100644 schedule/scheduler.py create mode 100644 test_async_scheduler.py diff --git a/requirements-dev.txt b/requirements-dev.txt index 5309656f..c4a90c5c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ +aiounittest docutils mock Pygments diff --git a/schedule/__init__.py b/schedule/__init__.py index 7b87e6a5..79171553 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -15,7 +15,7 @@ - A simple to use API for scheduling jobs. - Very lightweight and no external dependencies. - Excellent test coverage. - - Tested on Python 2.7, 3.5 and 3.6 + - Tested on Python 2.7, 3.5, 3.6, 3.7 and 3.8 Usage: >>> import schedule @@ -41,522 +41,11 @@ from collections.abc import Hashable except ImportError: from collections import Hashable -import datetime -import functools -import logging -import random -import re -import time - -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): - """ - Objects instantiated by the :class:`Scheduler ` are - factories to create jobs, keep record of scheduled jobs and - handle their execution. - """ - def __init__(self): - self.jobs = [] - - def run_pending(self): - """ - Run all jobs that are scheduled to run. - - Please note that it is *intended behavior that run_pending() - does not run missed jobs*. For example, if you've registered a job - that should run every minute and you only call run_pending() - in one hour increments then your job won't be run 60 times in - between but only once. - """ - runnable_jobs = (job for job in self.jobs if job.should_run) - for job in sorted(runnable_jobs): - self._run_job(job) - - def run_all(self, delay_seconds=0): - """ - Run all jobs regardless if they are scheduled to run or not. - - A delay of `delay` seconds is added between each job. This helps - distribute system load generated by the jobs more evenly - over time. - - :param delay_seconds: A delay added between every executed job - """ - logger.info('Running *all* %i jobs with %is delay inbetween', - len(self.jobs), delay_seconds) - for job in self.jobs[:]: - self._run_job(job) - time.sleep(delay_seconds) - - def clear(self, tag=None): - """ - Deletes scheduled jobs marked with the given tag, or all jobs - if tag is omitted. - - :param tag: An identifier used to identify a subset of - jobs to delete - """ - if tag is None: - del self.jobs[:] - else: - self.jobs[:] = (job for job in self.jobs if tag not in job.tags) - - def cancel_job(self, job): - """ - Delete a scheduled job. - - :param job: The job to be unscheduled - """ - try: - self.jobs.remove(job) - except ValueError: - pass - - def every(self, interval=1): - """ - Schedule a new periodic job. - - :param interval: A quantity of a certain time unit - :return: An unconfigured :class:`Job ` - """ - job = Job(interval, self) - return job - - def _run_job(self, job): - ret = job.run() - if isinstance(ret, CancelJob) or ret is CancelJob: - self.cancel_job(job) - - @property - def next_run(self): - """ - Datetime when the next job should run. - - :return: A :class:`~datetime.datetime` object - """ - if not self.jobs: - return None - return min(self.jobs).next_run - - @property - def idle_seconds(self): - """ - :return: Number of seconds until - :meth:`next_run `. - """ - return (self.next_run - datetime.datetime.now()).total_seconds() - - -class Job(object): - """ - A periodic job as used by :class:`Scheduler`. - - :param interval: A quantity of a certain time unit - :param scheduler: The :class:`Scheduler ` instance that - this job will register itself with once it has - been fully configured in :meth:`Job.do()`. - - Every job runs at a given fixed time interval that is defined by: - - * a :meth:`time unit ` - * a quantity of `time units` defined by `interval` - - A job is usually created and returned by :meth:`Scheduler.every` - method, which also defines its `interval`. - """ - def __init__(self, interval, scheduler=None): - self.interval = interval # pause interval * unit between runs - self.latest = None # upper limit to the interval - self.job_func = None # the job job_func to run - self.unit = None # time units, e.g. 'minutes', 'hours', ... - self.at_time = None # optional time at which this job runs - self.last_run = None # datetime of the last run - self.next_run = None # datetime of the next run - self.period = None # timedelta between runs, only valid for - 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 - - def __lt__(self, other): - """ - PeriodicJobs are sortable based on the scheduled time they - run next. - """ - return self.next_run < other.next_run - - def __str__(self): - return ( - "Job(interval={}, " - "unit={}, " - "do={}, " - "args={}, " - "kwargs={})" - ).format(self.interval, - self.unit, - self.job_func.__name__, - self.job_func.args, - self.job_func.keywords) - - def __repr__(self): - def format_time(t): - return t.strftime('%Y-%m-%d %H:%M:%S') if t else '[never]' - - def is_repr(j): - return not isinstance(j, Job) - - timestats = '(last run: %s, next run: %s)' % ( - format_time(self.last_run), format_time(self.next_run)) - - if hasattr(self.job_func, '__name__'): - job_func_name = self.job_func.__name__ - else: - job_func_name = repr(self.job_func) - args = [repr(x) if is_repr(x) else str(x) for x in self.job_func.args] - kwargs = ['%s=%s' % (k, repr(v)) - for k, v in self.job_func.keywords.items()] - call_repr = job_func_name + '(' + ', '.join(args + kwargs) + ')' - - if self.at_time is not None: - return 'Every %s %s at %s do %s %s' % ( - self.interval, - self.unit[:-1] if self.interval == 1 else self.unit, - self.at_time, call_repr, timestats) - else: - fmt = ( - 'Every %(interval)s ' + - ('to %(latest)s ' if self.latest is not None else '') + - '%(unit)s do %(call_repr)s %(timestats)s' - ) - - return fmt % dict( - interval=self.interval, - latest=self.latest, - unit=(self.unit[:-1] if self.interval == 1 else self.unit), - call_repr=call_repr, - timestats=timestats - ) - - @property - def second(self): - if self.interval != 1: - raise IntervalError('Use seconds instead of second') - return self.seconds - - @property - def seconds(self): - self.unit = 'seconds' - return self - - @property - def minute(self): - if self.interval != 1: - raise IntervalError('Use minutes instead of minute') - return self.minutes - - @property - def minutes(self): - self.unit = 'minutes' - return self - - @property - def hour(self): - if self.interval != 1: - raise IntervalError('Use hours instead of hour') - return self.hours - - @property - def hours(self): - self.unit = 'hours' - return self - - @property - def day(self): - if self.interval != 1: - raise IntervalError('Use days instead of day') - return self.days - - @property - def days(self): - self.unit = 'days' - return self - - @property - def week(self): - if self.interval != 1: - raise IntervalError('Use weeks instead of week') - return self.weeks - - @property - def weeks(self): - self.unit = 'weeks' - return self - - @property - def monday(self): - if self.interval != 1: - raise IntervalError('Use mondays instead of monday') - self.start_day = 'monday' - return self.weeks - - @property - def tuesday(self): - if self.interval != 1: - raise IntervalError('Use tuesdays instead of tuesday') - self.start_day = 'tuesday' - return self.weeks - - @property - def wednesday(self): - if self.interval != 1: - raise IntervalError('Use wednesdays instead of wednesday') - self.start_day = 'wednesday' - return self.weeks - - @property - def thursday(self): - if self.interval != 1: - raise IntervalError('Use thursdays instead of thursday') - self.start_day = 'thursday' - return self.weeks - - @property - def friday(self): - if self.interval != 1: - raise IntervalError('Use fridays instead of friday') - self.start_day = 'friday' - return self.weeks - - @property - def saturday(self): - if self.interval != 1: - raise IntervalError('Use saturdays instead of saturday') - self.start_day = 'saturday' - return self.weeks - - @property - def sunday(self): - if self.interval != 1: - raise IntervalError('Use sundays instead of sunday') - self.start_day = 'sunday' - return self.weeks - - def tag(self, *tags): - """ - Tags the job with one or more unique indentifiers. - - Tags must be hashable. Duplicate tags are discarded. - - :param tags: A unique list of ``Hashable`` tags. - :return: The invoked job instance - """ - if not all(isinstance(tag, Hashable) for tag in tags): - raise TypeError('Tags must be hashable') - self.tags.update(tags) - return self - - def at(self, time_str): - """ - Specify a particular time that the job should be run at. - - :param time_str: A string in one of the following formats: `HH:MM:SS`, - `HH:MM`,`:MM`, `:SS`. The format must make sense given how often - the job is repeating; for example, a job that repeats every minute - should not be given a string in the form `HH:MM:SS`. The difference - between `:MM` and `:SS` is inferred from the selected time-unit - (e.g. `every().hour.at(':30')` vs. `every().minute.at(':30')`). - :return: The invoked job instance - """ - if (self.unit not in ('days', 'hours', 'minutes') - and not self.start_day): - raise ScheduleValueError('Invalid unit') - if not isinstance(time_str, str): - raise TypeError('at() should be passed a string') - if self.unit == 'days' or self.start_day: - if not re.match(r'^([0-2]\d:)?[0-5]\d:[0-5]\d$', time_str): - raise ScheduleValueError('Invalid time format') - if self.unit == 'hours': - if not re.match(r'^([0-5]\d)?:[0-5]\d$', time_str): - raise ScheduleValueError(('Invalid time format for' - ' an hourly job')) - if self.unit == 'minutes': - if not re.match(r'^:[0-5]\d$', time_str): - raise ScheduleValueError(('Invalid time format for' - ' a minutely job')) - time_values = time_str.split(':') - if len(time_values) == 3: - hour, minute, second = time_values - elif len(time_values) == 2 and self.unit == 'minutes': - hour = 0 - minute = 0 - _, second = time_values - else: - hour, minute = time_values - second = 0 - if self.unit == 'days' or self.start_day: - hour = int(hour) - if not (0 <= hour <= 23): - raise ScheduleValueError('Invalid number of hours') - elif self.unit == 'hours': - hour = 0 - elif self.unit == 'minutes': - hour = 0 - minute = 0 - minute = int(minute) - second = int(second) - self.at_time = datetime.time(hour, minute, second) - return self - - def to(self, latest): - """ - Schedule the job to run at an irregular (randomized) interval. - - The job's interval will randomly vary from the value given - to `every` to `latest`. The range defined is inclusive on - both ends. For example, `every(A).to(B).seconds` executes - the job function every N seconds such that A <= N <= B. - - :param latest: Maximum interval between randomized job runs - :return: The invoked job instance - """ - self.latest = latest - return self - - def do(self, job_func, *args, **kwargs): - """ - Specifies the job_func that should be called every time the - job runs. - - Any additional arguments are passed on to job_func when - the job runs. - - :param job_func: The function to be scheduled - :return: The invoked job instance - """ - self.job_func = functools.partial(job_func, *args, **kwargs) - try: - functools.update_wrapper(self.job_func, job_func) - except AttributeError: - # job_funcs already wrapped by functools.partial won't have - # __name__, __module__ or __doc__ and the update_wrapper() - # call will fail. - pass - self._schedule_next_run() - self.scheduler.jobs.append(self) - return self - - @property - def should_run(self): - """ - :return: ``True`` if the job should be run now. - """ - return datetime.datetime.now() >= self.next_run - - def run(self): - """ - Run the job and immediately reschedule it. - - :return: The return value returned by the `job_func` - """ - logger.info('Running job %s', self) - ret = self.job_func() - self.last_run = datetime.datetime.now() - self._schedule_next_run() - return ret - - def _schedule_next_run(self): - """ - Compute the instant when this job should run next. - """ - if self.unit not in ('seconds', 'minutes', 'hours', 'days', 'weeks'): - raise ScheduleValueError('Invalid unit') - - if self.latest is not None: - if not (self.latest >= self.interval): - raise ScheduleError('`latest` is greater than `interval`') - interval = random.randint(self.interval, self.latest) - else: - interval = self.interval - - self.period = datetime.timedelta(**{self.unit: interval}) - self.next_run = datetime.datetime.now() + self.period - if self.start_day is not None: - if self.unit != 'weeks': - raise ScheduleValueError('`unit` should be \'weeks\'') - weekdays = ( - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', - 'sunday' - ) - if self.start_day not in weekdays: - raise ScheduleValueError('Invalid start day') - weekday = weekdays.index(self.start_day) - days_ahead = weekday - self.next_run.weekday() - if days_ahead <= 0: # Target day already happened this week - days_ahead += 7 - self.next_run += datetime.timedelta(days_ahead) - self.period - if self.at_time is not None: - if (self.unit not in ('days', 'hours', 'minutes') - and self.start_day is None): - raise ScheduleValueError(('Invalid unit without' - ' specifying start day')) - kwargs = { - 'second': self.at_time.second, - 'microsecond': 0 - } - if self.unit == 'days' or self.start_day is not None: - kwargs['hour'] = self.at_time.hour - if self.unit in ['days', 'hours'] or self.start_day is not None: - kwargs['minute'] = self.at_time.minute - self.next_run = self.next_run.replace(**kwargs) - # 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() - if (self.unit == 'days' and self.at_time > now.time() and - self.interval == 1): - self.next_run = self.next_run - datetime.timedelta(days=1) - elif self.unit == 'hours' \ - and self.at_time.minute > now.minute \ - or (self.at_time.minute == now.minute - and self.at_time.second > now.second): - self.next_run = self.next_run - datetime.timedelta(hours=1) - elif self.unit == 'minutes' \ - and self.at_time.second > now.second: - 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: - self.next_run -= self.period +from schedule.async_job import AsyncJob +from schedule.async_scheduler import AsyncScheduler +from schedule.job import * +from schedule.scheduler import * # The following methods are shortcuts for not having to # create a Scheduler instance: diff --git a/schedule/async_job.py b/schedule/async_job.py new file mode 100644 index 00000000..0612bb7a --- /dev/null +++ b/schedule/async_job.py @@ -0,0 +1,13 @@ +import inspect + +from schedule.job import Job + + +class AsyncJob(Job): + + async def run(self): + ret = super().run() + if inspect.isawaitable(ret): + ret = await ret + + return ret diff --git a/schedule/async_scheduler.py b/schedule/async_scheduler.py new file mode 100644 index 00000000..73476827 --- /dev/null +++ b/schedule/async_scheduler.py @@ -0,0 +1,24 @@ +import asyncio +import logging + +from schedule.scheduler import CancelJob, Scheduler + +logger = logging.getLogger('async_schedule') + + +class AsyncScheduler(Scheduler): + + async def run_pending(self): + runnable_jobs = (job for job in self.jobs if job.should_run) + await asyncio.gather(*[self._run_job(job) for job in runnable_jobs]) + + async def run_all(self, delay_seconds=0): + logger.info('Running *all* %i jobs with %is delay inbetween', len(self.jobs), delay_seconds) + for job in self.jobs[:]: + await self._run_job(job) + await asyncio.sleep(delay_seconds) + + async def _run_job(self, job): + ret = await job.run() + if isinstance(ret, CancelJob) or ret is CancelJob: + self.cancel_job(job) diff --git a/schedule/job.py b/schedule/job.py new file mode 100644 index 00000000..e6f111b1 --- /dev/null +++ b/schedule/job.py @@ -0,0 +1,372 @@ +try: + from collections.abc import Hashable +except ImportError: + from collections import Hashable +import datetime +import functools +import logging +import random +import re + +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 Job(object): + """ + A periodic job as used by :class:`Scheduler`. + + :param interval: A quantity of a certain time unit + :param scheduler: The :class:`Scheduler ` instance that + this job will register itself with once it has + been fully configured in :meth:`Job.do()`. + + Every job runs at a given fixed time interval that is defined by: + + * a :meth:`time unit ` + * a quantity of `time units` defined by `interval` + + A job is usually created and returned by :meth:`Scheduler.every` + method, which also defines its `interval`. + """ + + def __init__(self, interval, scheduler=None): + self.interval = interval # pause interval * unit between runs + self.latest = None # upper limit to the interval + self.job_func = None # the job job_func to run + self.unit = None # time units, e.g. 'minutes', 'hours', ... + self.at_time = None # optional time at which this job runs + self.last_run = None # datetime of the last run + self.next_run = None # datetime of the next run + self.period = None # timedelta between runs, only valid for + 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 + + def __lt__(self, other): + """ + PeriodicJobs are sortable based on the scheduled time they + run next. + """ + return self.next_run < other.next_run + + def __str__(self): + return ("Job(interval={}, " "unit={}, " "do={}, " "args={}, " "kwargs={})").format(self.interval, self.unit, + self.job_func.__name__, self.job_func.args, self.job_func.keywords, ) + + def __repr__(self): + def format_time(t): + return t.strftime("%Y-%m-%d %H:%M:%S") if t else "[never]" + + def is_repr(j): + return not isinstance(j, Job) + + timestats = "(last run: %s, next run: %s)" % (format_time(self.last_run), format_time(self.next_run),) + + if hasattr(self.job_func, "__name__"): + job_func_name = self.job_func.__name__ + else: + job_func_name = repr(self.job_func) + args = [repr(x) if is_repr(x) else str(x) for x in self.job_func.args] + kwargs = ["%s=%s" % (k, repr(v)) for k, v in self.job_func.keywords.items()] + call_repr = job_func_name + "(" + ", ".join(args + kwargs) + ")" + + if self.at_time is not None: + return "Every %s %s at %s do %s %s" % ( + self.interval, self.unit[:-1] if self.interval == 1 else self.unit, self.at_time, call_repr, timestats,) + else: + fmt = ("Every %(interval)s " + ( + "to %(latest)s " if self.latest is not None else "") + "%(unit)s do %(call_repr)s %(timestats)s") + + return fmt % dict(interval=self.interval, latest=self.latest, + unit=(self.unit[:-1] if self.interval == 1 else self.unit), call_repr=call_repr, timestats=timestats, ) + + @property + def second(self): + if self.interval != 1: + raise IntervalError("Use seconds instead of second") + return self.seconds + + @property + def seconds(self): + self.unit = "seconds" + return self + + @property + def minute(self): + if self.interval != 1: + raise IntervalError("Use minutes instead of minute") + return self.minutes + + @property + def minutes(self): + self.unit = "minutes" + return self + + @property + def hour(self): + if self.interval != 1: + raise IntervalError("Use hours instead of hour") + return self.hours + + @property + def hours(self): + self.unit = "hours" + return self + + @property + def day(self): + if self.interval != 1: + raise IntervalError("Use days instead of day") + return self.days + + @property + def days(self): + self.unit = "days" + return self + + @property + def week(self): + if self.interval != 1: + raise IntervalError("Use weeks instead of week") + return self.weeks + + @property + def weeks(self): + self.unit = "weeks" + return self + + @property + def monday(self): + if self.interval != 1: + raise IntervalError("Use mondays instead of monday") + self.start_day = "monday" + return self.weeks + + @property + def tuesday(self): + if self.interval != 1: + raise IntervalError("Use tuesdays instead of tuesday") + self.start_day = "tuesday" + return self.weeks + + @property + def wednesday(self): + if self.interval != 1: + raise IntervalError("Use wednesdays instead of wednesday") + self.start_day = "wednesday" + return self.weeks + + @property + def thursday(self): + if self.interval != 1: + raise IntervalError("Use thursdays instead of thursday") + self.start_day = "thursday" + return self.weeks + + @property + def friday(self): + if self.interval != 1: + raise IntervalError("Use fridays instead of friday") + self.start_day = "friday" + return self.weeks + + @property + def saturday(self): + if self.interval != 1: + raise IntervalError("Use saturdays instead of saturday") + self.start_day = "saturday" + return self.weeks + + @property + def sunday(self): + if self.interval != 1: + raise IntervalError("Use sundays instead of sunday") + self.start_day = "sunday" + return self.weeks + + def tag(self, *tags): + """ + Tags the job with one or more unique indentifiers. + + Tags must be hashable. Duplicate tags are discarded. + + :param tags: A unique list of ``Hashable`` tags. + :return: The invoked job instance + """ + if not all(isinstance(tag, Hashable) for tag in tags): + raise TypeError("Tags must be hashable") + self.tags.update(tags) + return self + + def at(self, time_str): + """ + Specify a particular time that the job should be run at. + + :param time_str: A string in one of the following formats: `HH:MM:SS`, + `HH:MM`,`:MM`, `:SS`. The format must make sense given how often + the job is repeating; for example, a job that repeats every minute + should not be given a string in the form `HH:MM:SS`. The difference + between `:MM` and `:SS` is inferred from the selected time-unit + (e.g. `every().hour.at(':30')` vs. `every().minute.at(':30')`). + :return: The invoked job instance + """ + if self.unit not in ("days", "hours", "minutes") and not self.start_day: + raise ScheduleValueError("Invalid unit") + if not isinstance(time_str, str): + raise TypeError("at() should be passed a string") + if self.unit == "days" or self.start_day: + if not re.match(r"^([0-2]\d:)?[0-5]\d:[0-5]\d$", time_str): + raise ScheduleValueError("Invalid time format") + if self.unit == "hours": + if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str): + raise ScheduleValueError(("Invalid time format for" " an hourly job")) + if self.unit == "minutes": + if not re.match(r"^:[0-5]\d$", time_str): + raise ScheduleValueError(("Invalid time format for" " a minutely job")) + time_values = time_str.split(":") + if len(time_values) == 3: + hour, minute, second = time_values + elif len(time_values) == 2 and self.unit == "minutes": + hour = 0 + minute = 0 + _, second = time_values + else: + hour, minute = time_values + second = 0 + if self.unit == "days" or self.start_day: + hour = int(hour) + if not (0 <= hour <= 23): + raise ScheduleValueError("Invalid number of hours") + elif self.unit == "hours": + hour = 0 + elif self.unit == "minutes": + hour = 0 + minute = 0 + minute = int(minute) + second = int(second) + self.at_time = datetime.time(hour, minute, second) + return self + + def to(self, latest): + """ + Schedule the job to run at an irregular (randomized) interval. + + The job's interval will randomly vary from the value given + to `every` to `latest`. The range defined is inclusive on + both ends. For example, `every(A).to(B).seconds` executes + the job function every N seconds such that A <= N <= B. + + :param latest: Maximum interval between randomized job runs + :return: The invoked job instance + """ + self.latest = latest + return self + + def do(self, job_func, *args, **kwargs): + """ + Specifies the job_func that should be called every time the + job runs. + + Any additional arguments are passed on to job_func when + the job runs. + + :param job_func: The function to be scheduled + :return: The invoked job instance + """ + self.job_func = functools.partial(job_func, *args, **kwargs) + try: + functools.update_wrapper(self.job_func, job_func) + except AttributeError: + # job_funcs already wrapped by functools.partial won't have + # __name__, __module__ or __doc__ and the update_wrapper() + # call will fail. + pass + self._schedule_next_run() + self.scheduler.jobs.append(self) + return self + + @property + def should_run(self): + """ + :return: ``True`` if the job should be run now. + """ + return datetime.datetime.now() >= self.next_run + + def run(self): + """ + Run the job and immediately reschedule it. + + :return: The return value returned by the `job_func` + """ + logger.info("Running job %s", self) + ret = self.job_func() + self.last_run = datetime.datetime.now() + self._schedule_next_run() + return ret + + def _schedule_next_run(self): + """ + Compute the instant when this job should run next. + """ + if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): + raise ScheduleValueError("Invalid unit") + + if self.latest is not None: + if not (self.latest >= self.interval): + raise ScheduleError("`latest` is greater than `interval`") + interval = random.randint(self.interval, self.latest) + else: + interval = self.interval + + self.period = datetime.timedelta(**{self.unit: interval}) + self.next_run = datetime.datetime.now() + self.period + if self.start_day is not None: + if self.unit != "weeks": + raise ScheduleValueError("`unit` should be 'weeks'") + weekdays = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",) + if self.start_day not in weekdays: + raise ScheduleValueError("Invalid start day") + weekday = weekdays.index(self.start_day) + days_ahead = weekday - self.next_run.weekday() + if days_ahead <= 0: # Target day already happened this week + days_ahead += 7 + self.next_run += datetime.timedelta(days_ahead) - self.period + if self.at_time is not None: + if self.unit not in ("days", "hours", "minutes") and self.start_day is None: + raise ScheduleValueError(("Invalid unit without" " specifying start day")) + kwargs = {"second": self.at_time.second, "microsecond": 0} + if self.unit == "days" or self.start_day is not None: + kwargs["hour"] = self.at_time.hour + if self.unit in ["days", "hours"] or self.start_day is not None: + kwargs["minute"] = self.at_time.minute + self.next_run = self.next_run.replace(**kwargs) + # 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() + if (self.unit == "days" and self.at_time > now.time() and self.interval == 1): + self.next_run = self.next_run - datetime.timedelta(days=1) + elif (self.unit == "hours" and self.at_time.minute > now.minute or ( + self.at_time.minute == now.minute and self.at_time.second > now.second)): + self.next_run = self.next_run - datetime.timedelta(hours=1) + elif self.unit == "minutes" and self.at_time.second > now.second: + 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: + self.next_run -= self.period diff --git a/schedule/scheduler.py b/schedule/scheduler.py new file mode 100644 index 00000000..ad1c60b5 --- /dev/null +++ b/schedule/scheduler.py @@ -0,0 +1,116 @@ +try: + from collections.abc import Hashable +except ImportError: + from collections import Hashable +import datetime +import logging +import time + +from schedule.job import Job + +logger = logging.getLogger('schedule') + + +class CancelJob(object): + """ + Can be returned from a job to unschedule itself. + """ + pass + + +class Scheduler(object): + """ + Objects instantiated by the :class:`Scheduler ` are + factories to create jobs, keep record of scheduled jobs and + handle their execution. + """ + + def __init__(self): + self.jobs = [] + + def run_pending(self): + """ + Run all jobs that are scheduled to run. + + Please note that it is *intended behavior that run_pending() + does not run missed jobs*. For example, if you've registered a job + that should run every minute and you only call run_pending() + in one hour increments then your job won't be run 60 times in + between but only once. + """ + runnable_jobs = (job for job in self.jobs if job.should_run) + for job in sorted(runnable_jobs): + self._run_job(job) + + def run_all(self, delay_seconds=0): + """ + Run all jobs regardless if they are scheduled to run or not. + + A delay of `delay` seconds is added between each job. This helps + distribute system load generated by the jobs more evenly + over time. + + :param delay_seconds: A delay added between every executed job + """ + logger.info('Running *all* %i jobs with %is delay inbetween', len(self.jobs), delay_seconds) + for job in self.jobs[:]: + self._run_job(job) + time.sleep(delay_seconds) + + def clear(self, tag=None): + """ + Deletes scheduled jobs marked with the given tag, or all jobs + if tag is omitted. + + :param tag: An identifier used to identify a subset of + jobs to delete + """ + if tag is None: + del self.jobs[:] + else: + self.jobs[:] = (job for job in self.jobs if tag not in job.tags) + + def cancel_job(self, job): + """ + Delete a scheduled job. + + :param job: The job to be unscheduled + """ + try: + self.jobs.remove(job) + except ValueError: + pass + + def every(self, interval=1): + """ + Schedule a new periodic job. + + :param interval: A quantity of a certain time unit + :return: An unconfigured :class:`Job ` + """ + job = Job(interval, self) + return job + + def _run_job(self, job): + ret = job.run() + if isinstance(ret, CancelJob) or ret is CancelJob: + self.cancel_job(job) + + @property + def next_run(self): + """ + Datetime when the next job should run. + + :return: A :class:`~datetime.datetime` object + """ + if not self.jobs: + return None + return min(self.jobs).next_run + + @property + def idle_seconds(self): + """ + :return: Number of seconds until + :meth:`next_run `. + """ + return (self.next_run - datetime.datetime.now()).total_seconds() diff --git a/setup.py b/setup.py index 21f83af8..d634b4b5 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ def read_file(filename): 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Natural Language :: English', ], python_requires='>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*', diff --git a/test_async_scheduler.py b/test_async_scheduler.py new file mode 100644 index 00000000..71206a5a --- /dev/null +++ b/test_async_scheduler.py @@ -0,0 +1,46 @@ +"""Unit tests for async_scheduler.py""" +import sys +import unittest + +if sys.version_info < (3, 5, 0): + raise unittest.SkipTest("Coroutines are supported since version 3.5") + +import asyncio +import datetime + +import aiounittest + +from schedule import AsyncScheduler + +async_scheduler = AsyncScheduler() + + +class AsyncSchedulerTest(aiounittest.AsyncTestCase): + def setUp(self): + async_scheduler.clear() + + @staticmethod + async def increment(array, index): + array[index] += 1 + + async def test_async_sample(self): + duration = 10 # seconds + test_array = [0] * duration + + for index, value in enumerate(test_array): + async_scheduler.every(index + 1).seconds.do(AsyncSchedulerTest.increment, test_array, index) + + start = datetime.datetime.now() + current = start + + while (current - start).total_seconds() < duration: + await async_scheduler.run_pending() + await asyncio.sleep(1) + current = datetime.datetime.now() + + for index, value in enumerate(test_array): + position = index + 1 + expected = duration / position + expected = int(expected) if expected != int(expected) else expected - 1 + + self.assertEqual(value, expected, msg=f'unexpected value for {position}th') From 436a948f05c3a88eab2405e5d3a596bf8013785b Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Sun, 15 Mar 2020 21:08:19 +0300 Subject: [PATCH 02/26] more async tests --- schedule/async_scheduler.py | 2 +- test_async_scheduler.py | 91 +++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/schedule/async_scheduler.py b/schedule/async_scheduler.py index 73476827..5d140dad 100644 --- a/schedule/async_scheduler.py +++ b/schedule/async_scheduler.py @@ -13,7 +13,7 @@ async def run_pending(self): await asyncio.gather(*[self._run_job(job) for job in runnable_jobs]) async def run_all(self, delay_seconds=0): - logger.info('Running *all* %i jobs with %is delay inbetween', len(self.jobs), delay_seconds) + logger.info(f'Running *all* {len(self.jobs)} jobs with {delay_seconds}s delay in between') for job in self.jobs[:]: await self._run_job(job) await asyncio.sleep(delay_seconds) diff --git a/test_async_scheduler.py b/test_async_scheduler.py index 71206a5a..1028ef7b 100644 --- a/test_async_scheduler.py +++ b/test_async_scheduler.py @@ -1,20 +1,32 @@ """Unit tests for async_scheduler.py""" +import datetime import sys import unittest +import mock + if sys.version_info < (3, 5, 0): - raise unittest.SkipTest("Coroutines are supported since version 3.5") + raise unittest.SkipTest("Coroutines declared with the async/await syntax are supported since version 3.5") import asyncio -import datetime - import aiounittest -from schedule import AsyncScheduler +from schedule import AsyncScheduler, CancelJob +from test_schedule import mock_datetime async_scheduler = AsyncScheduler() +def make_async_mock_job(name='async_job'): + job = mock.AsyncMock() + job.__name__ = name + return job + + +async def stop_job(): + return CancelJob + + class AsyncSchedulerTest(aiounittest.AsyncTestCase): def setUp(self): async_scheduler.clear() @@ -23,6 +35,7 @@ def setUp(self): async def increment(array, index): array[index] += 1 + @unittest.skip("slow demo test") async def test_async_sample(self): duration = 10 # seconds test_array = [0] * duration @@ -44,3 +57,73 @@ async def test_async_sample(self): expected = int(expected) if expected != int(expected) else expected - 1 self.assertEqual(value, expected, msg=f'unexpected value for {position}th') + + async def test_async_run_pending(self): + mock_job = make_async_mock_job() + + with mock_datetime(2010, 1, 6, 12, 15): + async_scheduler.every().minute.do(mock_job) + async_scheduler.every().hour.do(mock_job) + async_scheduler.every().day.do(mock_job) + async_scheduler.every().sunday.do(mock_job) + await async_scheduler.run_pending() + assert mock_job.call_count == 0 + + with mock_datetime(2010, 1, 6, 12, 16): + await async_scheduler.run_pending() + assert mock_job.call_count == 1 + + with mock_datetime(2010, 1, 6, 13, 16): + mock_job.reset_mock() + await async_scheduler.run_pending() + assert mock_job.call_count == 2 + + with mock_datetime(2010, 1, 7, 13, 16): + mock_job.reset_mock() + await async_scheduler.run_pending() + assert mock_job.call_count == 3 + + with mock_datetime(2010, 1, 10, 13, 16): + mock_job.reset_mock() + await async_scheduler.run_pending() + assert mock_job.call_count == 4 + + async def test_async_run_all(self): + mock_job = make_async_mock_job() + async_scheduler.every().minute.do(mock_job) + async_scheduler.every().hour.do(mock_job) + async_scheduler.every().day.at('11:00').do(mock_job) + await async_scheduler.run_all() + assert mock_job.call_count == 3 + + async def test_async_job_func_args_are_passed_on(self): + mock_job = make_async_mock_job() + async_scheduler.every().second.do(mock_job, 1, 2, 'three', foo=23, bar={}) + await async_scheduler.run_all() + mock_job.assert_called_once_with(1, 2, 'three', foo=23, bar={}) + + async def test_cancel_async_job(self): + mock_job = make_async_mock_job() + + async_scheduler.every().second.do(stop_job) + mj = async_scheduler.every().second.do(mock_job) + assert len(async_scheduler.jobs) == 2 + + await async_scheduler.run_all() + assert len(async_scheduler.jobs) == 1 + assert async_scheduler.jobs[0] == mj + + async_scheduler.cancel_job('Not a job') + assert len(async_scheduler.jobs) == 1 + + async_scheduler.cancel_job(mj) + assert len(async_scheduler.jobs) == 0 + + async def test_cancel_async_jobs(self): + async_scheduler.every().second.do(stop_job) + async_scheduler.every().second.do(stop_job) + async_scheduler.every().second.do(stop_job) + assert len(async_scheduler.jobs) == 3 + + await async_scheduler.run_all() + assert len(async_scheduler.jobs) == 0 From ec85135ea8ae4ef424111cacc51d2e833874f909 Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Sun, 15 Mar 2020 21:43:52 +0300 Subject: [PATCH 03/26] has supported tests for python2.7 --- conftest.py | 6 ++++++ requirements-dev.txt | 2 +- schedule/__init__.py | 8 ++++++-- test_async_scheduler.py | 1 - 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..2c79117f --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +# content of conftest.py +import sys + +collect_ignore = [] +if sys.version_info < (3, 5, 0): + collect_ignore.append("test_async_scheduler.py") diff --git a/requirements-dev.txt b/requirements-dev.txt index c4a90c5c..b7708ae0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -aiounittest +aiounittest; python_version >= '3.5' docutils mock Pygments diff --git a/schedule/__init__.py b/schedule/__init__.py index 79171553..ae643016 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -42,8 +42,12 @@ except ImportError: from collections import Hashable -from schedule.async_job import AsyncJob -from schedule.async_scheduler import AsyncScheduler +import sys + +if sys.version_info >= (3, 5, 0): + from schedule.async_job import AsyncJob + from schedule.async_scheduler import AsyncScheduler + from schedule.job import * from schedule.scheduler import * diff --git a/test_async_scheduler.py b/test_async_scheduler.py index 1028ef7b..c08d0887 100644 --- a/test_async_scheduler.py +++ b/test_async_scheduler.py @@ -2,7 +2,6 @@ import datetime import sys import unittest - import mock if sys.version_info < (3, 5, 0): From 31ec27fca15c31306e7e2be2c45829a6485e3bd3 Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Fri, 20 Mar 2020 00:01:59 +0300 Subject: [PATCH 04/26] AsyncScheduler: Inherited docs --- schedule/async_job.py | 9 +++++++++ schedule/async_scheduler.py | 37 ++++++++++++++++++++++++++++++++++++- schedule/job.py | 2 ++ schedule/scheduler.py | 2 ++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/schedule/async_job.py b/schedule/async_job.py index 0612bb7a..064ffe21 100644 --- a/schedule/async_job.py +++ b/schedule/async_job.py @@ -1,9 +1,16 @@ +# module: schedule +# file: async_job.py import inspect from schedule.job import Job +def _inherit_doc(doc): + return doc.replace('Scheduler', 'AsyncScheduler').replace('job', 'async job').replace('Job', 'AsyncJob') + + class AsyncJob(Job): + __doc__ = _inherit_doc(Job.__doc__) async def run(self): ret = super().run() @@ -11,3 +18,5 @@ async def run(self): ret = await ret return ret + + run.__doc__ = _inherit_doc(Job.__doc__) diff --git a/schedule/async_scheduler.py b/schedule/async_scheduler.py index 5d140dad..7b97aeb9 100644 --- a/schedule/async_scheduler.py +++ b/schedule/async_scheduler.py @@ -1,3 +1,29 @@ +""" +Python async job scheduling for humans. + +An in-process scheduler for periodic jobs that uses the builder pattern +for configuration. Schedule lets you run Python coroutines periodically +at pre-determined intervals using a simple, human-friendly syntax. + +Usage: + >>> import asyncio + >>> import schedule + >>> import time + + >>> async def job(message='stuff'): + >>> print("I'm working on:", message) + + >>> async_scheduler = schedule.AsyncScheduler() + + >>> async_scheduler.every(10).minutes.do(job) + >>> async_scheduler.every(5).to(10).days.do(job) + >>> async_scheduler.every().hour.do(job, message='things') + >>> async_scheduler.every().day.at("10:30").do(job) + + >>> while True: + >>> await schedule.run_pending() + >>> await asyncio.sleep(1) +""" import asyncio import logging @@ -6,18 +32,27 @@ logger = logging.getLogger('async_schedule') +def _inherit_doc(doc): + return doc.replace('Scheduler', 'AsyncScheduler').replace('job', 'async job') + + class AsyncScheduler(Scheduler): + __doc__ = _inherit_doc(Scheduler.__doc__) async def run_pending(self): runnable_jobs = (job for job in self.jobs if job.should_run) await asyncio.gather(*[self._run_job(job) for job in runnable_jobs]) + run_pending.__doc__ = _inherit_doc(Scheduler.run_pending.__doc__) + async def run_all(self, delay_seconds=0): - logger.info(f'Running *all* {len(self.jobs)} jobs with {delay_seconds}s delay in between') + logger.info(f'Running *all* {len(self.jobs)} async jobs with {delay_seconds}s delay in between') for job in self.jobs[:]: await self._run_job(job) await asyncio.sleep(delay_seconds) + run_all.__doc__ = _inherit_doc(Scheduler.run_all.__doc__) + async def _run_job(self, job): ret = await job.run() if isinstance(ret, CancelJob) or ret is CancelJob: diff --git a/schedule/job.py b/schedule/job.py index e6f111b1..a627b97d 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -1,3 +1,5 @@ +# module: schedule +# file: job.py try: from collections.abc import Hashable except ImportError: diff --git a/schedule/scheduler.py b/schedule/scheduler.py index ad1c60b5..b718e4b2 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -1,3 +1,5 @@ +# module: schedule +# file: scheduler.py try: from collections.abc import Hashable except ImportError: From 28b34e27e7aa0217599628f59a9be5f55d7ad8c8 Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Fri, 20 Mar 2020 01:00:32 +0300 Subject: [PATCH 05/26] AsyncScheduler: flake8 errors were fixed --- schedule/__init__.py | 19 ++-- schedule/async_job.py | 8 +- schedule/async_scheduler.py | 9 +- schedule/job.py | 209 ++++++++++++++++++++---------------- schedule/scheduler.py | 7 +- 5 files changed, 147 insertions(+), 105 deletions(-) diff --git a/schedule/__init__.py b/schedule/__init__.py index ae643016..d802d9a4 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -37,19 +37,24 @@ [2] https://github.com/Rykian/clockwork [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ """ -try: - from collections.abc import Hashable -except ImportError: - from collections import Hashable - import sys +from schedule.job import IntervalError, Job, ScheduleError, ScheduleValueError +from schedule.scheduler import CancelJob, Scheduler + +__all__ = [ + 'IntervalError', + 'Job', + 'ScheduleError', + 'ScheduleValueError', + 'CancelJob', + 'Scheduler'] + if sys.version_info >= (3, 5, 0): from schedule.async_job import AsyncJob from schedule.async_scheduler import AsyncScheduler -from schedule.job import * -from schedule.scheduler import * + __all__ += ['AsyncJob', 'AsyncScheduler'] # The following methods are shortcuts for not having to # create a Scheduler instance: diff --git a/schedule/async_job.py b/schedule/async_job.py index 064ffe21..8f20c7fc 100644 --- a/schedule/async_job.py +++ b/schedule/async_job.py @@ -6,7 +6,13 @@ def _inherit_doc(doc): - return doc.replace('Scheduler', 'AsyncScheduler').replace('job', 'async job').replace('Job', 'AsyncJob') + return doc.replace( + 'Scheduler', + 'AsyncScheduler').replace( + 'job', + 'async job').replace( + 'Job', + 'AsyncJob') class AsyncJob(Job): diff --git a/schedule/async_scheduler.py b/schedule/async_scheduler.py index 7b97aeb9..2b8fbda7 100644 --- a/schedule/async_scheduler.py +++ b/schedule/async_scheduler.py @@ -33,7 +33,11 @@ def _inherit_doc(doc): - return doc.replace('Scheduler', 'AsyncScheduler').replace('job', 'async job') + return doc.replace( + 'Scheduler', + 'AsyncScheduler').replace( + 'job', + 'async job') class AsyncScheduler(Scheduler): @@ -46,7 +50,8 @@ async def run_pending(self): run_pending.__doc__ = _inherit_doc(Scheduler.run_pending.__doc__) async def run_all(self, delay_seconds=0): - logger.info(f'Running *all* {len(self.jobs)} async jobs with {delay_seconds}s delay in between') + logger.info('Running *all* %i async jobs with %is delay in between', + len(self.jobs), delay_seconds) for job in self.jobs[:]: await self._run_job(job) await asyncio.sleep(delay_seconds) diff --git a/schedule/job.py b/schedule/job.py index a627b97d..a3530eaf 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -31,21 +31,16 @@ class IntervalError(ScheduleValueError): class Job(object): """ A periodic job as used by :class:`Scheduler`. - :param interval: A quantity of a certain time unit :param scheduler: The :class:`Scheduler ` instance that this job will register itself with once it has been fully configured in :meth:`Job.do()`. - Every job runs at a given fixed time interval that is defined by: - * a :meth:`time unit ` * a quantity of `time units` defined by `interval` - A job is usually created and returned by :meth:`Scheduler.every` method, which also defines its `interval`. """ - def __init__(self, interval, scheduler=None): self.interval = interval # pause interval * unit between runs self.latest = None # upper limit to the interval @@ -67,158 +62,176 @@ def __lt__(self, other): return self.next_run < other.next_run def __str__(self): - return ("Job(interval={}, " "unit={}, " "do={}, " "args={}, " "kwargs={})").format(self.interval, self.unit, - self.job_func.__name__, self.job_func.args, self.job_func.keywords, ) + return ( + "Job(interval={}, " + "unit={}, " + "do={}, " + "args={}, " + "kwargs={})" + ).format(self.interval, + self.unit, + self.job_func.__name__, + self.job_func.args, + self.job_func.keywords) 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') if t else '[never]' def is_repr(j): return not isinstance(j, Job) - timestats = "(last run: %s, next run: %s)" % (format_time(self.last_run), format_time(self.next_run),) + timestats = '(last run: %s, next run: %s)' % ( + format_time(self.last_run), format_time(self.next_run)) - if hasattr(self.job_func, "__name__"): + if hasattr(self.job_func, '__name__'): job_func_name = self.job_func.__name__ else: job_func_name = repr(self.job_func) args = [repr(x) if is_repr(x) else str(x) for x in self.job_func.args] - kwargs = ["%s=%s" % (k, repr(v)) for k, v in self.job_func.keywords.items()] - call_repr = job_func_name + "(" + ", ".join(args + kwargs) + ")" + kwargs = ['%s=%s' % (k, repr(v)) + for k, v in self.job_func.keywords.items()] + call_repr = job_func_name + '(' + ', '.join(args + kwargs) + ')' if self.at_time is not None: - return "Every %s %s at %s do %s %s" % ( - self.interval, self.unit[:-1] if self.interval == 1 else self.unit, self.at_time, call_repr, timestats,) + return 'Every %s %s at %s do %s %s' % ( + self.interval, + self.unit[:-1] if self.interval == 1 else self.unit, + self.at_time, call_repr, timestats) else: - fmt = ("Every %(interval)s " + ( - "to %(latest)s " if self.latest is not None else "") + "%(unit)s do %(call_repr)s %(timestats)s") - - return fmt % dict(interval=self.interval, latest=self.latest, - unit=(self.unit[:-1] if self.interval == 1 else self.unit), call_repr=call_repr, timestats=timestats, ) + fmt = ( + 'Every %(interval)s ' + + ('to %(latest)s ' if self.latest is not None else '') + + '%(unit)s do %(call_repr)s %(timestats)s' + ) + + return fmt % dict( + interval=self.interval, + latest=self.latest, + unit=(self.unit[:-1] if self.interval == 1 else self.unit), + call_repr=call_repr, + timestats=timestats + ) @property def second(self): if self.interval != 1: - raise IntervalError("Use seconds instead of second") + raise IntervalError('Use seconds instead of second') return self.seconds @property def seconds(self): - self.unit = "seconds" + self.unit = 'seconds' return self @property def minute(self): if self.interval != 1: - raise IntervalError("Use minutes instead of minute") + raise IntervalError('Use minutes instead of minute') return self.minutes @property def minutes(self): - self.unit = "minutes" + self.unit = 'minutes' return self @property def hour(self): if self.interval != 1: - raise IntervalError("Use hours instead of hour") + raise IntervalError('Use hours instead of hour') return self.hours @property def hours(self): - self.unit = "hours" + self.unit = 'hours' return self @property def day(self): if self.interval != 1: - raise IntervalError("Use days instead of day") + raise IntervalError('Use days instead of day') return self.days @property def days(self): - self.unit = "days" + self.unit = 'days' return self @property def week(self): if self.interval != 1: - raise IntervalError("Use weeks instead of week") + raise IntervalError('Use weeks instead of week') return self.weeks @property def weeks(self): - self.unit = "weeks" + self.unit = 'weeks' return self @property def monday(self): if self.interval != 1: - raise IntervalError("Use mondays instead of monday") - self.start_day = "monday" + raise IntervalError('Use mondays instead of monday') + self.start_day = 'monday' return self.weeks @property def tuesday(self): if self.interval != 1: - raise IntervalError("Use tuesdays instead of tuesday") - self.start_day = "tuesday" + raise IntervalError('Use tuesdays instead of tuesday') + self.start_day = 'tuesday' return self.weeks @property def wednesday(self): if self.interval != 1: - raise IntervalError("Use wednesdays instead of wednesday") - self.start_day = "wednesday" + raise IntervalError('Use wednesdays instead of wednesday') + self.start_day = 'wednesday' return self.weeks @property def thursday(self): if self.interval != 1: - raise IntervalError("Use thursdays instead of thursday") - self.start_day = "thursday" + raise IntervalError('Use thursdays instead of thursday') + self.start_day = 'thursday' return self.weeks @property def friday(self): if self.interval != 1: - raise IntervalError("Use fridays instead of friday") - self.start_day = "friday" + raise IntervalError('Use fridays instead of friday') + self.start_day = 'friday' return self.weeks @property def saturday(self): if self.interval != 1: - raise IntervalError("Use saturdays instead of saturday") - self.start_day = "saturday" + raise IntervalError('Use saturdays instead of saturday') + self.start_day = 'saturday' return self.weeks @property def sunday(self): if self.interval != 1: - raise IntervalError("Use sundays instead of sunday") - self.start_day = "sunday" + raise IntervalError('Use sundays instead of sunday') + self.start_day = 'sunday' return self.weeks def tag(self, *tags): """ Tags the job with one or more unique indentifiers. - Tags must be hashable. Duplicate tags are discarded. - :param tags: A unique list of ``Hashable`` tags. :return: The invoked job instance """ if not all(isinstance(tag, Hashable) for tag in tags): - raise TypeError("Tags must be hashable") + raise TypeError('Tags must be hashable') self.tags.update(tags) return self def at(self, time_str): """ Specify a particular time that the job should be run at. - :param time_str: A string in one of the following formats: `HH:MM:SS`, `HH:MM`,`:MM`, `:SS`. The format must make sense given how often the job is repeating; for example, a job that repeats every minute @@ -227,36 +240,39 @@ def at(self, time_str): (e.g. `every().hour.at(':30')` vs. `every().minute.at(':30')`). :return: The invoked job instance """ - if self.unit not in ("days", "hours", "minutes") and not self.start_day: - raise ScheduleValueError("Invalid unit") + if (self.unit not in ('days', 'hours', 'minutes') + and not self.start_day): + raise ScheduleValueError('Invalid unit') if not isinstance(time_str, str): - raise TypeError("at() should be passed a string") - if self.unit == "days" or self.start_day: - if not re.match(r"^([0-2]\d:)?[0-5]\d:[0-5]\d$", time_str): - raise ScheduleValueError("Invalid time format") - if self.unit == "hours": - if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str): - raise ScheduleValueError(("Invalid time format for" " an hourly job")) - if self.unit == "minutes": - if not re.match(r"^:[0-5]\d$", time_str): - raise ScheduleValueError(("Invalid time format for" " a minutely job")) - time_values = time_str.split(":") + raise TypeError('at() should be passed a string') + if self.unit == 'days' or self.start_day: + if not re.match(r'^([0-2]\d:)?[0-5]\d:[0-5]\d$', time_str): + raise ScheduleValueError('Invalid time format') + if self.unit == 'hours': + if not re.match(r'^([0-5]\d)?:[0-5]\d$', time_str): + raise ScheduleValueError(('Invalid time format for' + ' an hourly job')) + if self.unit == 'minutes': + if not re.match(r'^:[0-5]\d$', time_str): + raise ScheduleValueError(('Invalid time format for' + ' a minutely job')) + time_values = time_str.split(':') if len(time_values) == 3: hour, minute, second = time_values - elif len(time_values) == 2 and self.unit == "minutes": + elif len(time_values) == 2 and self.unit == 'minutes': hour = 0 minute = 0 _, second = time_values else: hour, minute = time_values second = 0 - if self.unit == "days" or self.start_day: + if self.unit == 'days' or self.start_day: hour = int(hour) if not (0 <= hour <= 23): - raise ScheduleValueError("Invalid number of hours") - elif self.unit == "hours": + raise ScheduleValueError('Invalid number of hours') + elif self.unit == 'hours': hour = 0 - elif self.unit == "minutes": + elif self.unit == 'minutes': hour = 0 minute = 0 minute = int(minute) @@ -267,12 +283,10 @@ def at(self, time_str): def to(self, latest): """ Schedule the job to run at an irregular (randomized) interval. - The job's interval will randomly vary from the value given to `every` to `latest`. The range defined is inclusive on both ends. For example, `every(A).to(B).seconds` executes the job function every N seconds such that A <= N <= B. - :param latest: Maximum interval between randomized job runs :return: The invoked job instance """ @@ -283,10 +297,8 @@ def do(self, job_func, *args, **kwargs): """ Specifies the job_func that should be called every time the job runs. - Any additional arguments are passed on to job_func when the job runs. - :param job_func: The function to be scheduled :return: The invoked job instance """ @@ -312,10 +324,9 @@ def should_run(self): def run(self): """ Run the job and immediately reschedule it. - :return: The return value returned by the `job_func` """ - logger.info("Running job %s", self) + logger.info('Running job %s', self) ret = self.job_func() self.last_run = datetime.datetime.now() self._schedule_next_run() @@ -325,12 +336,12 @@ def _schedule_next_run(self): """ Compute the instant when this job should run next. """ - if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): - raise ScheduleValueError("Invalid unit") + if self.unit not in ('seconds', 'minutes', 'hours', 'days', 'weeks'): + raise ScheduleValueError('Invalid unit') if self.latest is not None: if not (self.latest >= self.interval): - raise ScheduleError("`latest` is greater than `interval`") + raise ScheduleError('`latest` is greater than `interval`') interval = random.randint(self.interval, self.latest) else: interval = self.interval @@ -338,36 +349,54 @@ def _schedule_next_run(self): self.period = datetime.timedelta(**{self.unit: interval}) self.next_run = datetime.datetime.now() + self.period if self.start_day is not None: - if self.unit != "weeks": - raise ScheduleValueError("`unit` should be 'weeks'") - weekdays = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",) + if self.unit != 'weeks': + raise ScheduleValueError('`unit` should be \'weeks\'') + weekdays = ( + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' + ) if self.start_day not in weekdays: - raise ScheduleValueError("Invalid start day") + raise ScheduleValueError('Invalid start day') weekday = weekdays.index(self.start_day) days_ahead = weekday - self.next_run.weekday() if days_ahead <= 0: # Target day already happened this week days_ahead += 7 self.next_run += datetime.timedelta(days_ahead) - self.period if self.at_time is not None: - if self.unit not in ("days", "hours", "minutes") and self.start_day is None: - raise ScheduleValueError(("Invalid unit without" " specifying start day")) - kwargs = {"second": self.at_time.second, "microsecond": 0} - if self.unit == "days" or self.start_day is not None: - kwargs["hour"] = self.at_time.hour - if self.unit in ["days", "hours"] or self.start_day is not None: - kwargs["minute"] = self.at_time.minute + if (self.unit not in ('days', 'hours', 'minutes') + and self.start_day is None): + raise ScheduleValueError(('Invalid unit without' + ' specifying start day')) + kwargs = { + 'second': self.at_time.second, + 'microsecond': 0 + } + if self.unit == 'days' or self.start_day is not None: + kwargs['hour'] = self.at_time.hour + if self.unit in ['days', 'hours'] or self.start_day is not None: + kwargs['minute'] = self.at_time.minute self.next_run = self.next_run.replace(**kwargs) # 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() - if (self.unit == "days" and self.at_time > now.time() and self.interval == 1): + if (self.unit == 'days' and self.at_time > now.time() and + self.interval == 1): self.next_run = self.next_run - datetime.timedelta(days=1) - elif (self.unit == "hours" and self.at_time.minute > now.minute or ( - self.at_time.minute == now.minute and self.at_time.second > now.second)): + elif self.unit == 'hours' \ + and self.at_time.minute > now.minute \ + or (self.at_time.minute == now.minute + and self.at_time.second > now.second): self.next_run = self.next_run - datetime.timedelta(hours=1) - elif self.unit == "minutes" and self.at_time.second > now.second: - self.next_run = self.next_run - datetime.timedelta(minutes=1) + elif self.unit == 'minutes' \ + and self.at_time.second > now.second: + 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: diff --git a/schedule/scheduler.py b/schedule/scheduler.py index b718e4b2..ce533288 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -1,9 +1,5 @@ # module: schedule # file: scheduler.py -try: - from collections.abc import Hashable -except ImportError: - from collections import Hashable import datetime import logging import time @@ -54,7 +50,8 @@ def run_all(self, delay_seconds=0): :param delay_seconds: A delay added between every executed job """ - logger.info('Running *all* %i jobs with %is delay inbetween', len(self.jobs), delay_seconds) + logger.info('Running *all* %i jobs with %is delay inbetween', + len(self.jobs), delay_seconds) for job in self.jobs[:]: self._run_job(job) time.sleep(delay_seconds) From 48bc866770a95c2459a8d8876cd53c41e84fd8e6 Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Fri, 20 Mar 2020 01:38:02 +0300 Subject: [PATCH 06/26] AsyncScheduler: docs were fixed --- .travis.yml | 1 + schedule/job.py | 12 ++++++++++++ test_async_scheduler.py | 29 +++++++++++++++++------------ tox.ini | 2 +- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5850c6b2..13cfe29d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: - "3.5" - "3.6" - "3.7" + - "3.8" - "3.8-dev" - "nightly" install: pip install tox-travis coveralls diff --git a/schedule/job.py b/schedule/job.py index a3530eaf..6ca29d91 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -31,13 +31,17 @@ class IntervalError(ScheduleValueError): class Job(object): """ A periodic job as used by :class:`Scheduler`. + :param interval: A quantity of a certain time unit :param scheduler: The :class:`Scheduler ` instance that this job will register itself with once it has been fully configured in :meth:`Job.do()`. + Every job runs at a given fixed time interval that is defined by: + * a :meth:`time unit ` * a quantity of `time units` defined by `interval` + A job is usually created and returned by :meth:`Scheduler.every` method, which also defines its `interval`. """ @@ -220,7 +224,9 @@ def sunday(self): def tag(self, *tags): """ Tags the job with one or more unique indentifiers. + Tags must be hashable. Duplicate tags are discarded. + :param tags: A unique list of ``Hashable`` tags. :return: The invoked job instance """ @@ -232,6 +238,7 @@ def tag(self, *tags): def at(self, time_str): """ Specify a particular time that the job should be run at. + :param time_str: A string in one of the following formats: `HH:MM:SS`, `HH:MM`,`:MM`, `:SS`. The format must make sense given how often the job is repeating; for example, a job that repeats every minute @@ -283,10 +290,12 @@ def at(self, time_str): def to(self, latest): """ Schedule the job to run at an irregular (randomized) interval. + The job's interval will randomly vary from the value given to `every` to `latest`. The range defined is inclusive on both ends. For example, `every(A).to(B).seconds` executes the job function every N seconds such that A <= N <= B. + :param latest: Maximum interval between randomized job runs :return: The invoked job instance """ @@ -297,8 +306,10 @@ def do(self, job_func, *args, **kwargs): """ Specifies the job_func that should be called every time the job runs. + Any additional arguments are passed on to job_func when the job runs. + :param job_func: The function to be scheduled :return: The invoked job instance """ @@ -324,6 +335,7 @@ def should_run(self): def run(self): """ Run the job and immediately reschedule it. + :return: The return value returned by the `job_func` """ logger.info('Running job %s', self) diff --git a/test_async_scheduler.py b/test_async_scheduler.py index c08d0887..6007c4ed 100644 --- a/test_async_scheduler.py +++ b/test_async_scheduler.py @@ -1,17 +1,17 @@ """Unit tests for async_scheduler.py""" +from test_schedule import mock_datetime import datetime import sys import unittest import mock -if sys.version_info < (3, 5, 0): - raise unittest.SkipTest("Coroutines declared with the async/await syntax are supported since version 3.5") - -import asyncio -import aiounittest - -from schedule import AsyncScheduler, CancelJob -from test_schedule import mock_datetime +if sys.version_info >= (3, 5, 0): + from schedule import AsyncScheduler, CancelJob + import aiounittest + import asyncio +else: + raise unittest.SkipTest("Coroutines declared with the async/await \ + syntax are supported since version 3.5") async_scheduler = AsyncScheduler() @@ -40,7 +40,9 @@ async def test_async_sample(self): test_array = [0] * duration for index, value in enumerate(test_array): - async_scheduler.every(index + 1).seconds.do(AsyncSchedulerTest.increment, test_array, index) + async_scheduler.every( + index + 1).seconds.do(AsyncSchedulerTest.increment, + test_array, index) start = datetime.datetime.now() current = start @@ -53,9 +55,11 @@ async def test_async_sample(self): for index, value in enumerate(test_array): position = index + 1 expected = duration / position - expected = int(expected) if expected != int(expected) else expected - 1 + expected = int(expected) if expected != int( + expected) else expected - 1 - self.assertEqual(value, expected, msg=f'unexpected value for {position}th') + self.assertEqual( + value, expected, msg=f'unexpected value for {position}th') async def test_async_run_pending(self): mock_job = make_async_mock_job() @@ -97,7 +101,8 @@ async def test_async_run_all(self): async def test_async_job_func_args_are_passed_on(self): mock_job = make_async_mock_job() - async_scheduler.every().second.do(mock_job, 1, 2, 'three', foo=23, bar={}) + async_scheduler.every().second.do(mock_job, 1, 2, + 'three', foo=23, bar={}) await async_scheduler.run_all() mock_job.assert_called_once_with(1, 2, 'three', foo=23, bar={}) diff --git a/tox.ini b/tox.ini index 2c5b93d7..56b206e2 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ skip_missing_interpreters = true [testenv] deps = -rrequirements-dev.txt commands = - py.test test_schedule.py --flake8 schedule -v --cov schedule --cov-report term-missing + py.test test_schedule.py test_async_scheduler.py --flake8 schedule -v --cov schedule --cov-report term-missing python setup.py check --strict --metadata --restructuredtext [testenv:docs] From a120fc8f1b3aae1b9d47a5365bdcedf55974ccc6 Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Fri, 20 Mar 2020 02:10:08 +0300 Subject: [PATCH 07/26] AsyncScheduler: ignore async by python2.7 --- test_async_scheduler.py | 3 ++- tox.ini | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/test_async_scheduler.py b/test_async_scheduler.py index 6007c4ed..0ffb0774 100644 --- a/test_async_scheduler.py +++ b/test_async_scheduler.py @@ -57,9 +57,10 @@ async def test_async_sample(self): expected = duration / position expected = int(expected) if expected != int( expected) else expected - 1 + error_msg = "unexpected value for {}th".format(position) self.assertEqual( - value, expected, msg=f'unexpected value for {position}th') + value, expected, msg=error_msg) async def test_async_run_pending(self): mock_job = make_async_mock_job() diff --git a/tox.ini b/tox.ini index 56b206e2..6f6fef08 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,14 @@ skip_missing_interpreters = true 3.7 = py37, docs 3.8 = py38, docs +[testenv:py27] +deps = -rrequirements-dev.txt +commands = + py.test test_schedule.py --ignore=schedule/async_scheduler.py \ + --ignore=schedule/async_job.py --ignore=test_async_scheduler.py \ + --flake8 schedule -v --cov schedule --cov-report term-missing + python setup.py check --strict --metadata --restructuredtext + [testenv] deps = -rrequirements-dev.txt commands = From b77e2eb4dad0d005fec4d662995cf6cd0b01fb45 Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Fri, 20 Mar 2020 02:17:50 +0300 Subject: [PATCH 08/26] AsyncScheduler: AsyncMock is supported since version 3.6 --- test_async_scheduler.py | 5 ++--- tox.ini | 10 +++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/test_async_scheduler.py b/test_async_scheduler.py index 0ffb0774..ff312f40 100644 --- a/test_async_scheduler.py +++ b/test_async_scheduler.py @@ -5,13 +5,12 @@ import unittest import mock -if sys.version_info >= (3, 5, 0): +if sys.version_info >= (3, 6, 0): from schedule import AsyncScheduler, CancelJob import aiounittest import asyncio else: - raise unittest.SkipTest("Coroutines declared with the async/await \ - syntax are supported since version 3.5") + raise unittest.SkipTest("AsyncMock is supported since version 3.6") async_scheduler = AsyncScheduler() diff --git a/tox.ini b/tox.ini index 6f6fef08..9035c359 100644 --- a/tox.ini +++ b/tox.ini @@ -17,10 +17,18 @@ commands = --flake8 schedule -v --cov schedule --cov-report term-missing python setup.py check --strict --metadata --restructuredtext +[testenv:py35] +deps = -rrequirements-dev.txt +commands = + py.test test_schedule.py --flake8 schedule -v --cov schedule \ + --cov-report term-missing + python setup.py check --strict --metadata --restructuredtext + [testenv] deps = -rrequirements-dev.txt commands = - py.test test_schedule.py test_async_scheduler.py --flake8 schedule -v --cov schedule --cov-report term-missing + py.test test_schedule.py test_async_scheduler.py --flake8 schedule -v \ + --cov schedule --cov-report term-missing python setup.py check --strict --metadata --restructuredtext [testenv:docs] From 9039b7ac1b5cedf8d949faeba6c066634bdd0135 Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Fri, 20 Mar 2020 20:48:34 +0300 Subject: [PATCH 09/26] AsyncScheduler: mistake in inheritance of run doc --- schedule/async_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schedule/async_job.py b/schedule/async_job.py index 8f20c7fc..fbe0f946 100644 --- a/schedule/async_job.py +++ b/schedule/async_job.py @@ -25,4 +25,4 @@ async def run(self): return ret - run.__doc__ = _inherit_doc(Job.__doc__) + run.__doc__ = _inherit_doc(Job.run.__doc__) From 8bcca32d2c2bd21ed762856816d746e4ff6632dc Mon Sep 17 00:00:00 2001 From: Roman Pirogov Date: Sat, 21 Mar 2020 00:18:21 +0300 Subject: [PATCH 10/26] AsyncScheduler: redundant AsyncJob --- schedule/__init__.py | 3 +-- schedule/async_job.py | 28 ---------------------------- schedule/async_scheduler.py | 11 +++++++---- schedule/scheduler.py | 7 +++++-- test_async_scheduler.py | 30 ++++++++++++++++++++++-------- tox.ini | 4 ++-- 6 files changed, 37 insertions(+), 46 deletions(-) delete mode 100644 schedule/async_job.py diff --git a/schedule/__init__.py b/schedule/__init__.py index d802d9a4..d5136238 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -51,10 +51,9 @@ 'Scheduler'] if sys.version_info >= (3, 5, 0): - from schedule.async_job import AsyncJob from schedule.async_scheduler import AsyncScheduler - __all__ += ['AsyncJob', 'AsyncScheduler'] + __all__ += ['AsyncScheduler'] # The following methods are shortcuts for not having to # create a Scheduler instance: diff --git a/schedule/async_job.py b/schedule/async_job.py deleted file mode 100644 index fbe0f946..00000000 --- a/schedule/async_job.py +++ /dev/null @@ -1,28 +0,0 @@ -# module: schedule -# file: async_job.py -import inspect - -from schedule.job import Job - - -def _inherit_doc(doc): - return doc.replace( - 'Scheduler', - 'AsyncScheduler').replace( - 'job', - 'async job').replace( - 'Job', - 'AsyncJob') - - -class AsyncJob(Job): - __doc__ = _inherit_doc(Job.__doc__) - - async def run(self): - ret = super().run() - if inspect.isawaitable(ret): - ret = await ret - - return ret - - run.__doc__ = _inherit_doc(Job.run.__doc__) diff --git a/schedule/async_scheduler.py b/schedule/async_scheduler.py index 2b8fbda7..a3496033 100644 --- a/schedule/async_scheduler.py +++ b/schedule/async_scheduler.py @@ -25,9 +25,10 @@ >>> await asyncio.sleep(1) """ import asyncio +import inspect import logging -from schedule.scheduler import CancelJob, Scheduler +from schedule.scheduler import Scheduler logger = logging.getLogger('async_schedule') @@ -59,6 +60,8 @@ async def run_all(self, delay_seconds=0): run_all.__doc__ = _inherit_doc(Scheduler.run_all.__doc__) async def _run_job(self, job): - ret = await job.run() - if isinstance(ret, CancelJob) or ret is CancelJob: - self.cancel_job(job) + ret = job.run() + if inspect.isawaitable(ret): + ret = await ret + + super()._check_returned_value(job, ret) diff --git a/schedule/scheduler.py b/schedule/scheduler.py index ce533288..2c3928cf 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -90,11 +90,14 @@ def every(self, interval=1): job = Job(interval, self) return job - def _run_job(self, job): - ret = job.run() + def _check_returned_value(self, job, ret): if isinstance(ret, CancelJob) or ret is CancelJob: self.cancel_job(job) + def _run_job(self, job): + ret = job.run() + self._check_returned_value(job, ret) + @property def next_run(self): """ diff --git a/test_async_scheduler.py b/test_async_scheduler.py index ff312f40..4cc97cd7 100644 --- a/test_async_scheduler.py +++ b/test_async_scheduler.py @@ -1,18 +1,20 @@ """Unit tests for async_scheduler.py""" -from test_schedule import mock_datetime import datetime import sys import unittest + import mock +from test_schedule import make_mock_job, mock_datetime + if sys.version_info >= (3, 6, 0): - from schedule import AsyncScheduler, CancelJob + import schedule import aiounittest import asyncio else: raise unittest.SkipTest("AsyncMock is supported since version 3.6") -async_scheduler = AsyncScheduler() +async_scheduler = schedule.AsyncScheduler() def make_async_mock_job(name='async_job'): @@ -22,7 +24,7 @@ def make_async_mock_job(name='async_job'): async def stop_job(): - return CancelJob + return schedule.CancelJob class AsyncSchedulerTest(aiounittest.AsyncTestCase): @@ -58,8 +60,7 @@ async def test_async_sample(self): expected) else expected - 1 error_msg = "unexpected value for {}th".format(position) - self.assertEqual( - value, expected, msg=error_msg) + self.assertEqual(value, expected, msg=error_msg) async def test_async_run_pending(self): mock_job = make_async_mock_job() @@ -101,8 +102,8 @@ async def test_async_run_all(self): async def test_async_job_func_args_are_passed_on(self): mock_job = make_async_mock_job() - async_scheduler.every().second.do(mock_job, 1, 2, - 'three', foo=23, bar={}) + async_scheduler.every().second.do(mock_job, 1, 2, 'three', + foo=23, bar={}) await async_scheduler.run_all() mock_job.assert_called_once_with(1, 2, 'three', foo=23, bar={}) @@ -131,3 +132,16 @@ async def test_cancel_async_jobs(self): await async_scheduler.run_all() assert len(async_scheduler.jobs) == 0 + + async def test_mixed_sync_async_tasks(self): + async_func = make_async_mock_job() + sync_func = make_mock_job() + + async_scheduler.every().second.do(async_func) + async_scheduler.every().second.do(sync_func) + assert async_func.call_count == 0 + assert sync_func.call_count == 0 + + await async_scheduler.run_all() + assert async_func.call_count == 1 + assert sync_func.call_count == 1 diff --git a/tox.ini b/tox.ini index 9035c359..986274e0 100644 --- a/tox.ini +++ b/tox.ini @@ -13,8 +13,8 @@ skip_missing_interpreters = true deps = -rrequirements-dev.txt commands = py.test test_schedule.py --ignore=schedule/async_scheduler.py \ - --ignore=schedule/async_job.py --ignore=test_async_scheduler.py \ - --flake8 schedule -v --cov schedule --cov-report term-missing + --ignore=test_async_scheduler.py --flake8 schedule -v \ + --cov schedule --cov-report term-missing python setup.py check --strict --metadata --restructuredtext [testenv:py35] From b40748446ff8c098dbde423104e72e49f0db9e7e Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 20:33:59 +0300 Subject: [PATCH 11/26] Added verification python3.9 --- .github/workflows/ci.yml | 14 +++++++------- .travis.yml | 19 ------------------- schedule/__init__.py | 2 +- setup.py | 1 + tox.ini | 3 ++- 5 files changed, 11 insertions(+), 28 deletions(-) delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83a9b25a..564b0837 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: max-parallel: 6 matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -25,13 +25,13 @@ jobs: run: pip install tox tox-gh-actions - name: Tests run: tox - - name: Set up Python 3.8 + - name: Set up Python 3.9 # A recent version of coveralls is required for Github # Actions support, which is only available for python3 if: matrix.python-version == '2.7' uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Coveralls env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -47,10 +47,10 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Finished run: | pip3 install coveralls @@ -64,10 +64,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: pip install tox - name: Check docs diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 13cfe29d..00000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -dist: xenial -language: python -python: - - "2.7" - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "3.8-dev" - - "nightly" -install: pip install tox-travis coveralls -script: - - tox -after_success: - - coveralls -matrix: - allow_failures: - - python: "3.8-dev" - - python: "nightly" diff --git a/schedule/__init__.py b/schedule/__init__.py index d5136238..eae4d105 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -15,7 +15,7 @@ - A simple to use API for scheduling jobs. - Very lightweight and no external dependencies. - Excellent test coverage. - - Tested on Python 2.7, 3.5, 3.6, 3.7 and 3.8 + - Tested on Python 2.7, 3.5, 3.6, 3.7, 3.8 and 3.9 Usage: >>> import schedule diff --git a/setup.py b/setup.py index d634b4b5..b7c64a71 100644 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ def read_file(filename): 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Natural Language :: English', ], python_requires='>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*', diff --git a/tox.ini b/tox.ini index cfa28db5..2fa37caf 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py3{5,6,7,8}, docs +envlist = py27, py3{5,6,7,8,9}, docs skip_missing_interpreters = true @@ -10,6 +10,7 @@ python = 3.6: py36 3.7: py37 3.8: py38 + 3.9: py39 [testenv:py27] deps = -rrequirements-dev.txt From 962f17a2815d2663fe85d631069ad49fa76a7044 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 21:11:20 +0300 Subject: [PATCH 12/26] fixed test_weekday_at_todady --- schedule/async_scheduler.py | 4 ++-- schedule/job.py | 10 ++++++---- schedule/scheduler.py | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/schedule/async_scheduler.py b/schedule/async_scheduler.py index a3496033..539f36f6 100644 --- a/schedule/async_scheduler.py +++ b/schedule/async_scheduler.py @@ -51,8 +51,8 @@ async def run_pending(self): run_pending.__doc__ = _inherit_doc(Scheduler.run_pending.__doc__) async def run_all(self, delay_seconds=0): - logger.info('Running *all* %i async jobs with %is delay in between', - len(self.jobs), delay_seconds) + logger.debug('Running *all* %i jobs with %is delay in between', + len(self.jobs), delay_seconds) for job in self.jobs[:]: await self._run_job(job) await asyncio.sleep(delay_seconds) diff --git a/schedule/job.py b/schedule/job.py index 6ca29d91..3a4e2056 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -338,7 +338,7 @@ def run(self): :return: The return value returned by the `job_func` """ - logger.info('Running job %s', self) + logger.debug('Running job %s', self) ret = self.job_func() self.last_run = datetime.datetime.now() self._schedule_next_run() @@ -401,9 +401,11 @@ def _schedule_next_run(self): self.interval == 1): self.next_run = self.next_run - datetime.timedelta(days=1) elif self.unit == 'hours' \ - and self.at_time.minute > now.minute \ - or (self.at_time.minute == now.minute - and self.at_time.second > now.second): + and ( + self.at_time.minute > now.minute + or (self.at_time.minute == now.minute + and self.at_time.second > now.second) + ): self.next_run = self.next_run - datetime.timedelta(hours=1) elif self.unit == 'minutes' \ and self.at_time.second > now.second: diff --git a/schedule/scheduler.py b/schedule/scheduler.py index 32b81014..ed25c4b4 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -50,8 +50,8 @@ def run_all(self, delay_seconds=0): :param delay_seconds: A delay added between every executed job """ - logger.info('Running *all* %i jobs with %is delay inbetween', - len(self.jobs), delay_seconds) + logger.debug('Running *all* %i jobs with %is delay inbetween', + len(self.jobs), delay_seconds) for job in self.jobs[:]: self._run_job(job) time.sleep(delay_seconds) @@ -104,6 +104,7 @@ def next_run(self): Datetime when the next job should run. :return: A :class:`~datetime.datetime` object + or None if no jobs scheduled """ if not self.jobs: return None From fc313143953e202822d15ff718217f70773be842 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 21:55:05 +0300 Subject: [PATCH 13/26] Do not check asynchronous functions for Python 2.7 and 3.5 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 564b0837..a2531827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: needs: test runs-on: ubuntu-latest steps: + - name: Do not check asynchronous functions for Python 2.7 and 3.5 + if: matrix.python-version == '2.7' && matrix.python-version == '3.5' + run: rm ./schedule/async_scheduler.py test_async_scheduler.py - name: Set up Python 3.9 uses: actions/setup-python@v2 with: From 29fb0df97b0cb3d279f37055d9f5f558ec77f2fe Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 22:16:34 +0300 Subject: [PATCH 14/26] erase extra spaces --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2531827..cdd8c0c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Do not check asynchronous functions for Python 2.7 and 3.5 - if: matrix.python-version == '2.7' && matrix.python-version == '3.5' - run: rm ./schedule/async_scheduler.py test_async_scheduler.py + if: matrix.python-version == '2.7' && matrix.python-version == '3.5' + run: rm ./schedule/async_scheduler.py test_async_scheduler.py - name: Set up Python 3.9 uses: actions/setup-python@v2 with: From a18c70140e1c56360a2641aec0f94494754c0932 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 22:21:32 +0300 Subject: [PATCH 15/26] Oops: Do not check asynchronous functions for Python 2.7 or 3.5 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdd8c0c7..724caf60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,8 +47,8 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - name: Do not check asynchronous functions for Python 2.7 and 3.5 - if: matrix.python-version == '2.7' && matrix.python-version == '3.5' + - name: Do not check asynchronous functions for Python 2.7 or 3.5 + if: matrix.python-version == '2.7' || matrix.python-version == '3.5' run: rm ./schedule/async_scheduler.py test_async_scheduler.py - name: Set up Python 3.9 uses: actions/setup-python@v2 From cd0cbfb65f69da899e8aa0aa06da327071b258d9 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 22:36:06 +0300 Subject: [PATCH 16/26] added strategy for coveralls --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 724caf60..ae20b2bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,10 @@ jobs: name: coverage push needs: test runs-on: ubuntu-latest + strategy: + max-parallel: 6 + matrix: + python-version: [ 2.7, 3.5, 3.6, 3.7, 3.8, 3.9 ] steps: - name: Do not check asynchronous functions for Python 2.7 or 3.5 if: matrix.python-version == '2.7' || matrix.python-version == '3.5' From e6807cdec459e46b5eb5c4922cdefa92331baeb0 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 22:57:25 +0300 Subject: [PATCH 17/26] using custom coveralls for 2.7 and 3.5 --- .coveragerc2.7and3.5 | 3 +++ .github/workflows/ci.yml | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 .coveragerc2.7and3.5 diff --git a/.coveragerc2.7and3.5 b/.coveragerc2.7and3.5 new file mode 100644 index 00000000..622a1cbd --- /dev/null +++ b/.coveragerc2.7and3.5 @@ -0,0 +1,3 @@ +[report] +omit = + schedule/async_* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae20b2bb..7fa2a7f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,15 @@ jobs: uses: actions/setup-python@v2 with: python-version: 3.9 + - name: Coveralls for Python 2.7 or 3.5 + if: matrix.python-version == '2.7' || matrix.python-version == '3.5' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: python-${{ matrix.python-version }} + COVERALLS_PARALLEL: true + run: | + pip3 install coveralls + coveralls --rcfile=.coveragerc2.7and3.5 - name: Coveralls env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -46,14 +55,7 @@ jobs: name: coverage push needs: test runs-on: ubuntu-latest - strategy: - max-parallel: 6 - matrix: - python-version: [ 2.7, 3.5, 3.6, 3.7, 3.8, 3.9 ] steps: - - name: Do not check asynchronous functions for Python 2.7 or 3.5 - if: matrix.python-version == '2.7' || matrix.python-version == '3.5' - run: rm ./schedule/async_scheduler.py test_async_scheduler.py - name: Set up Python 3.9 uses: actions/setup-python@v2 with: From eaed4f36d84e4b300b63e47ca1394694b8cf7aeb Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 23:02:19 +0300 Subject: [PATCH 18/26] Coveralls for others --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fa2a7f2..6df2649e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,8 @@ jobs: run: | pip3 install coveralls coveralls --rcfile=.coveragerc2.7and3.5 - - name: Coveralls + - name: Coveralls for others + if: matrix.python-version != '2.7' || matrix.python-version != '3.5' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: python-${{ matrix.python-version }} From 4220e3e30237f32c6aa7b675aa0dcc71ae22c77a Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 4 Jan 2021 23:08:43 +0300 Subject: [PATCH 19/26] misstype with condition --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6df2649e..9dfdb6b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: pip3 install coveralls coveralls --rcfile=.coveragerc2.7and3.5 - name: Coveralls for others - if: matrix.python-version != '2.7' || matrix.python-version != '3.5' + if: matrix.python-version != '2.7' && matrix.python-version != '3.5' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: python-${{ matrix.python-version }} From 8e1c263ec130c513101b2cdaff8c832232d7d2ee Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Tue, 5 Jan 2021 22:16:06 +0300 Subject: [PATCH 20/26] fixed unexpected indentation --- schedule/job.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/schedule/job.py b/schedule/job.py index 0241ce8d..de3667bf 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -238,16 +238,20 @@ def tag(self, *tags): def at(self, time_str): """ Specify a particular time that the job should be run at. + :param time_str: A string in one of the following formats: + - For daily jobs -> `HH:MM:SS` or `HH:MM` - For hourly jobs -> `MM:SS` or `:MM` - For minute jobs -> `:SS` + The format must make sense given how often the job is repeating; for example, a job that repeats every minute should not be given a string in the form `HH:MM:SS`. The difference between `:MM` and :SS` is inferred from the selected time-unit (e.g. `every().hour.at(':30')` vs. `every().minute.at(':30')`). + :return: The invoked job instance """ if (self.unit not in ('days', 'hours', 'minutes') From 9b43f6a052dd92bc4c25adda4f6cc874d32a03c2 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Sun, 19 Sep 2021 22:16:45 +0300 Subject: [PATCH 21/26] download ci.yml --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92110ecd..b023fd1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,10 +40,10 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - name: Set up Python 3.9 + - name: Set up Python 3.8 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.8 - name: Finished run: | pip3 install coveralls @@ -55,10 +55,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 + - name: Set up Python 3.8 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.8 - name: Install dependencies run: pip install tox - name: Check docs @@ -88,4 +88,4 @@ jobs: - name: Install dependencies run: pip install tox - name: Check docs - run: tox -e setuppy + run: tox -e setuppy \ No newline at end of file From af177c7c1081daeaf4791baca731e2e3ed7077bc Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Sun, 19 Sep 2021 22:47:25 +0300 Subject: [PATCH 22/26] erase __all__ --- schedule/__init__.py | 11 +---------- schedule/scheduler.py | 1 + 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/schedule/__init__.py b/schedule/__init__.py index fbfa705f..a2c12725 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -37,6 +37,7 @@ [2] https://github.com/Rykian/clockwork [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ """ +import datetime import sys from collections import Hashable from typing import List, Optional @@ -45,16 +46,6 @@ from schedule.job import IntervalError, Job, ScheduleError, ScheduleValueError from schedule.scheduler import CancelJob, Scheduler -__all__ = [ - "IntervalError", - "Job", - "ScheduleError", - "ScheduleValueError", - "CancelJob", - "Scheduler", - "AsyncScheduler", -] - # The following methods are shortcuts for not having to # create a Scheduler instance: diff --git a/schedule/scheduler.py b/schedule/scheduler.py index 35d5ec96..f766249e 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -3,6 +3,7 @@ import datetime import logging import time +from collections import Hashable from typing import List, Optional from schedule.job import Job From 61591c91ba4430e07147fe2d7c2d243dee235603 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Sun, 19 Sep 2021 23:07:54 +0300 Subject: [PATCH 23/26] come back repeat --- schedule/__init__.py | 15 +++++++++++++++ schedule/scheduler.py | 7 +++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/schedule/__init__.py b/schedule/__init__.py index a2c12725..f0de736e 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -110,3 +110,18 @@ def idle_seconds() -> Optional[float]: :data:`default scheduler instance `. """ return default_scheduler.idle_seconds + + +def repeat(job, *args, **kwargs): + """ + Decorator to schedule a new periodic job. + Any additional arguments are passed on to the decorated function + when the job runs. + :param job: a :class:`Jobs ` + """ + + def _schedule_decorator(decorated_function): + job.do(decorated_function, *args, **kwargs) + return decorated_function + + return diff --git a/schedule/scheduler.py b/schedule/scheduler.py index f766249e..21c47470 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -46,15 +46,13 @@ def run_pending(self) -> None: def run_all(self, delay_seconds: int = 0) -> None: """ Run all jobs regardless if they are scheduled to run or not. - A delay of `delay` seconds is added between each job. This helps distribute system load generated by the jobs more evenly over time. - :param delay_seconds: A delay added between every executed job """ logger.debug( - "Running *all* %i jobs with %is delay inbetween", + "Running *all* %i jobs with %is delay in between", len(self.jobs), delay_seconds, ) @@ -78,13 +76,14 @@ def clear(self, tag: Optional[Hashable] = None) -> None: """ Deletes scheduled jobs marked with the given tag, or all jobs if tag is omitted. - :param tag: An identifier used to identify a subset of jobs to delete """ if tag is None: + logger.debug("Deleting *all* jobs") del self.jobs[:] else: + logger.debug('Deleting all jobs tagged "%s"', tag) self.jobs[:] = (job for job in self.jobs if tag not in job.tags) def cancel_job(self, job: "Job") -> None: From c1fa96d777113c436710c712982f308d3748a469 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Sun, 19 Sep 2021 23:50:44 +0300 Subject: [PATCH 24/26] come back Job.until --- schedule/job.py | 157 ++++++++++++++++++++++++++++++++++-------- schedule/scheduler.py | 15 ++-- 2 files changed, 135 insertions(+), 37 deletions(-) diff --git a/schedule/job.py b/schedule/job.py index c5d541a8..9d64a0fb 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -5,9 +5,8 @@ import logging import random import re - from collections import Hashable -from typing import Callable, Optional, Set, Union +from typing import Callable, List, Optional, Set, Union logger = logging.getLogger("schedule") @@ -48,7 +47,7 @@ class Job(object): method, which also defines its `interval`. """ - def __init__(self, interval, scheduler=None): + def __init__(self, interval: int, scheduler: "Scheduler" = None): self.interval: int = interval # pause interval * unit between runs self.latest: Optional[int] = None # upper limit to the interval self.job_func: Optional[functools.partial] = None # the job job_func to run @@ -71,6 +70,9 @@ def __init__(self, interval, scheduler=None): # Specific day of the week to start on self.start_day: Optional[str] = None + # optional time of final run + self.cancel_after: Optional[datetime.datetime] = None + self.tags: Set[Hashable] = set() # unique set of tags for the job self.scheduler: Optional[Scheduler] = scheduler # scheduler to register with @@ -83,13 +85,11 @@ def __lt__(self, other) -> bool: def __str__(self) -> str: if hasattr(self.job_func, "__name__"): - job_func_name = self.job_func.__name__ + job_func_name = self.job_func.__name__ # type: ignore else: job_func_name = repr(self.job_func) - return ( - "Job(interval={}, " "unit={}, " "do={}, " "args={}, " "kwargs={})" - ).format( + return ("Job(interval={}, unit={}, do={}, args={}, kwargs={})").format( self.interval, self.unit, job_func_name, @@ -306,18 +306,27 @@ def at(self, time_str): :return: The invoked job instance """ if self.unit not in ("days", "hours", "minutes") and not self.start_day: - raise ScheduleValueError("Invalid unit") + raise ScheduleValueError( + "Invalid unit (valid units are `days`, `hours`, and `minutes`)" + ) if not isinstance(time_str, str): raise TypeError("at() should be passed a string") if self.unit == "days" or self.start_day: if not re.match(r"^([0-2]\d:)?[0-5]\d:[0-5]\d$", time_str): - raise ScheduleValueError("Invalid time format") + raise ScheduleValueError( + "Invalid time format for a daily job (valid format is HH:MM(:SS)?)" + ) if self.unit == "hours": if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str): - raise ScheduleValueError(("Invalid time format for" " an hourly job")) + raise ScheduleValueError( + "Invalid time format for an hourly job (valid format is (MM)?:SS)" + ) + if self.unit == "minutes": if not re.match(r"^:[0-5]\d$", time_str): - raise ScheduleValueError(("Invalid time format for" " a minutely job")) + raise ScheduleValueError( + "Invalid time format for a minutely job (valid format is :SS)" + ) time_values = time_str.split(":") hour: Union[str, int] minute: Union[str, int] @@ -328,13 +337,18 @@ def at(self, time_str): hour = 0 minute = 0 _, second = time_values + elif len(time_values) == 2 and self.unit == "hours" and len(time_values[0]): + hour = 0 + minute, second = time_values else: hour, minute = time_values second = 0 if self.unit == "days" or self.start_day: hour = int(hour) if not (0 <= hour <= 23): - raise ScheduleValueError("Invalid number of hours") + raise ScheduleValueError( + "Invalid number of hours ({} is not between 0 and 23)" + ) elif self.unit == "hours": hour = 0 elif self.unit == "minutes": @@ -360,6 +374,69 @@ def to(self, latest: int): self.latest = latest return self + def until( + self, + until_time: Union[datetime.datetime, datetime.timedelta, datetime.time, str], + ): + """ + Schedule job to run until the specified moment. + The job is canceled whenever the next run is calculated and it turns out the + next run is after the until_time. The job is also canceled right before it runs, + if the current time is after until_time. This latter case can happen when the + the job was scheduled to run before until_time, but runs after until_time. + If until_time is a moment in the past, ScheduleValueError is thrown. + + :param until_time: A moment in the future representing the latest time a job can be run. + If only a time is supplied, the date is set to today. The following formats are accepted: + - datetime.datetime + - datetime.timedelta + - datetime.time + - String in one of the following formats: "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M:%S", "%H:%M" + as defined by strptime() behaviour. If an invalid string format is passed, + ScheduleValueError is thrown. + + :return: The invoked job instance + """ + if isinstance(until_time, datetime.datetime): + self.cancel_after = until_time + elif isinstance(until_time, datetime.timedelta): + self.cancel_after = datetime.datetime.now() + until_time + elif isinstance(until_time, datetime.time): + self.cancel_after = datetime.datetime.combine( + datetime.datetime.now(), until_time + ) + elif isinstance(until_time, str): + cancel_after = self._decode_datetimestr( + until_time, + [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%d", + "%H:%M:%S", + "%H:%M", + ], + ) + if cancel_after is None: + raise ScheduleValueError("Invalid string format for until()") + if "-" not in until_time: + # the until_time is a time-only format. Set the date to today + now = datetime.datetime.now() + cancel_after = cancel_after.replace( + year=now.year, month=now.month, day=now.day + ) + self.cancel_after = cancel_after + else: + raise TypeError( + "until() takes a string, datetime.datetime, datetime.timedelta, " + "datetime.time parameter" + ) + if self.cancel_after < datetime.datetime.now(): + raise ScheduleValueError( + "Cannot schedule a job to run until a time in the past" + ) + return self + def do(self, job_func: Callable, *args, **kwargs): """ Specifies the job_func that should be called every time the @@ -372,13 +449,7 @@ def do(self, job_func: Callable, *args, **kwargs): :return: The invoked job instance """ self.job_func = functools.partial(job_func, *args, **kwargs) - try: - functools.update_wrapper(self.job_func, job_func) - except AttributeError: - # job_funcs already wrapped by functools.partial won't have - # __name__, __module__ or __doc__ and the update_wrapper() - # call will fail. - pass + functools.update_wrapper(self.job_func, job_func) self._schedule_next_run() if self.scheduler is None: raise ScheduleError( @@ -399,13 +470,26 @@ def should_run(self) -> bool: def run(self): """ Run the job and immediately reschedule it. + If the job's deadline is reached (configured using .until()), the job is not + run and CancelJob is returned immediately. If the next scheduled run exceeds + the job's deadline, CancelJob is returned after the execution. In this latter + case CancelJob takes priority over any other returned value. - :return: The return value returned by the `job_func` + :return: The return value returned by the `job_func`, or CancelJob if the job's + deadline is reached. """ + if self._is_overdue(datetime.datetime.now()): + 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._schedule_next_run() + + if self._is_overdue(self.next_run): + logger.debug("Cancelling job %s", self) + return CancelJob return ret def _schedule_next_run(self) -> None: @@ -413,7 +497,10 @@ def _schedule_next_run(self) -> None: Compute the instant when this job should run next. """ if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): - raise ScheduleValueError("Invalid unit") + raise ScheduleValueError( + "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " + "`days`, and `weeks`)" + ) if self.latest is not None: if not (self.latest >= self.interval): @@ -437,7 +524,9 @@ def _schedule_next_run(self) -> None: "sunday", ) if self.start_day not in weekdays: - raise ScheduleValueError("Invalid start day") + raise ScheduleValueError( + "Invalid start day (valid start days are {})".format(weekdays) + ) weekday = weekdays.index(self.start_day) days_ahead = weekday - self.next_run.weekday() if days_ahead <= 0: # Target day already happened this week @@ -445,18 +534,17 @@ def _schedule_next_run(self) -> None: self.next_run += datetime.timedelta(days_ahead) - self.period if self.at_time is not None: if self.unit not in ("days", "hours", "minutes") and self.start_day is None: - raise ScheduleValueError( - ("Invalid unit without" " specifying start day") - ) + raise ScheduleValueError("Invalid unit without specifying start day") kwargs = {"second": self.at_time.second, "microsecond": 0} if self.unit == "days" or self.start_day is not None: kwargs["hour"] = self.at_time.hour if self.unit in ["days", "hours"] or self.start_day is not None: kwargs["minute"] = self.at_time.minute self.next_run = self.next_run.replace(**kwargs) # type: ignore - # 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: + # Make sure we run at the specified time *today* (or *this hour*) + # 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() if ( self.unit == "days" @@ -478,3 +566,16 @@ def _schedule_next_run(self) -> None: # Let's see if we will still make that time we specified today if (self.next_run - datetime.datetime.now()).days >= 7: self.next_run -= self.period + + def _is_overdue(self, when: datetime.datetime): + return self.cancel_after is not None and when > self.cancel_after + + def _decode_datetimestr( + self, datetime_str: str, formats: List[str] + ) -> Optional[datetime.datetime]: + for f in formats: + try: + return datetime.datetime.strptime(datetime_str, f) + except ValueError: + pass + return None diff --git a/schedule/scheduler.py b/schedule/scheduler.py index 21c47470..e1edee61 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -32,7 +32,6 @@ def __init__(self) -> None: def run_pending(self) -> None: """ Run all jobs that are scheduled to run. - Please note that it is *intended behavior that run_pending() does not run missed jobs*. For example, if you've registered a job that should run every minute and you only call run_pending() @@ -64,6 +63,7 @@ def get_jobs(self, tag: Optional[Hashable] = None) -> List["Job"]: """ Gets scheduled jobs marked with the given tag, or all jobs if tag is omitted. + :param tag: An identifier used to identify a subset of jobs to retrieve """ @@ -76,6 +76,7 @@ def clear(self, tag: Optional[Hashable] = None) -> None: """ Deletes scheduled jobs marked with the given tag, or all jobs if tag is omitted. + :param tag: An identifier used to identify a subset of jobs to delete """ @@ -89,31 +90,27 @@ def clear(self, tag: Optional[Hashable] = None) -> None: def cancel_job(self, job: "Job") -> None: """ Delete a scheduled job. - :param job: The job to be unscheduled """ try: + logger.debug('Cancelling job "%s"', str(job)) self.jobs.remove(job) except ValueError: - pass + logger.debug('Cancelling not-scheduled job "%s"', str(job)) def every(self, interval: int = 1) -> "Job": """ Schedule a new periodic job. - :param interval: A quantity of a certain time unit :return: An unconfigured :class:`Job ` """ job = Job(interval, self) return job - def _check_returned_value(self, job, ret): - if isinstance(ret, CancelJob) or ret is CancelJob: - self.cancel_job(job) - def _run_job(self, job: "Job") -> None: ret = job.run() - self._check_returned_value(job, ret) + if isinstance(ret, CancelJob) or ret is CancelJob: + self.cancel_job(job) @property def next_run(self) -> Optional[datetime.datetime]: From 1d7754e29449a55a54b976d60fa19ca0b16f76c6 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Mon, 20 Sep 2021 00:11:22 +0300 Subject: [PATCH 25/26] fixed repeat decorator --- schedule/__init__.py | 12 +++++++++--- schedule/job.py | 8 ++++++++ schedule/scheduler.py | 17 ++++++----------- test_async_scheduler.py | 6 +++--- test_schedule.py | 16 ++++++---------- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/schedule/__init__.py b/schedule/__init__.py index f0de736e..3640eb7c 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -43,8 +43,14 @@ from typing import List, Optional from schedule.async_scheduler import AsyncScheduler -from schedule.job import IntervalError, Job, ScheduleError, ScheduleValueError -from schedule.scheduler import CancelJob, Scheduler +from schedule.job import ( + CancelJob, + IntervalError, + Job, + ScheduleError, + ScheduleValueError, +) +from schedule.scheduler import Scheduler # The following methods are shortcuts for not having to # create a Scheduler instance: @@ -124,4 +130,4 @@ def _schedule_decorator(decorated_function): job.do(decorated_function, *args, **kwargs) return decorated_function - return + return _schedule_decorator diff --git a/schedule/job.py b/schedule/job.py index 9d64a0fb..7d765ac9 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -29,6 +29,14 @@ class IntervalError(ScheduleValueError): pass +class CancelJob(object): + """ + Can be returned from a job to unschedule itself. + """ + + pass + + class Job(object): """ A periodic job as used by :class:`Scheduler`. diff --git a/schedule/scheduler.py b/schedule/scheduler.py index e1edee61..f3e70d59 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -6,19 +6,11 @@ from collections import Hashable from typing import List, Optional -from schedule.job import Job +from schedule.job import CancelJob, Job logger = logging.getLogger("schedule") -class CancelJob(object): - """ - Can be returned from a job to unschedule itself. - """ - - pass - - class Scheduler(object): """ Objects instantiated by the :class:`Scheduler ` are @@ -107,11 +99,14 @@ def every(self, interval: int = 1) -> "Job": job = Job(interval, self) return job - def _run_job(self, job: "Job") -> None: - ret = job.run() + def _check_returned_value(self, job, ret): if isinstance(ret, CancelJob) or ret is CancelJob: self.cancel_job(job) + def _run_job(self, job: "Job") -> None: + ret = job.run() + self._check_returned_value(job, ret) + @property def next_run(self) -> Optional[datetime.datetime]: """ diff --git a/test_async_scheduler.py b/test_async_scheduler.py index 0a6e168d..c1f27159 100644 --- a/test_async_scheduler.py +++ b/test_async_scheduler.py @@ -1,9 +1,9 @@ """Unit tests for async_scheduler.py""" import datetime +import mock import sys import unittest - -import mock +from typing import Callable from test_schedule import make_mock_job, mock_datetime @@ -17,7 +17,7 @@ async_scheduler = schedule.AsyncScheduler() -def make_async_mock_job(name="async_job"): +def make_async_mock_job(name="async_job") -> Callable: job = mock.AsyncMock() job.__name__ = name return job diff --git a/test_schedule.py b/test_schedule.py index 5ed97ea8..1fb148f5 100644 --- a/test_schedule.py +++ b/test_schedule.py @@ -3,22 +3,18 @@ import functools import mock import unittest +from typing import Callable + +import schedule +from schedule import IntervalError, ScheduleError, ScheduleValueError, every, repeat + # 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 -import schedule -from schedule import ( - every, - repeat, - ScheduleError, - ScheduleValueError, - IntervalError, -) - -def make_mock_job(name=None): +def make_mock_job(name=None) -> Callable: job = mock.Mock() job.__name__ = name or "job" return job From 8f38d570316275e80d0f7d65203f5be0f37d6828 Mon Sep 17 00:00:00 2001 From: Pirogov Roman Date: Wed, 22 Sep 2021 23:44:41 +0300 Subject: [PATCH 26/26] ABC class BaseScheduler --- schedule/__init__.py | 3 +- schedule/async_scheduler.py | 4 +- schedule/job.py | 13 ++++-- schedule/scheduler.py | 86 ++++++++++++++++++++----------------- 4 files changed, 60 insertions(+), 46 deletions(-) diff --git a/schedule/__init__.py b/schedule/__init__.py index 3640eb7c..6f17a5ab 100644 --- a/schedule/__init__.py +++ b/schedule/__init__.py @@ -38,8 +38,7 @@ [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ """ import datetime -import sys -from collections import Hashable +from collections.abc import Hashable from typing import List, Optional from schedule.async_scheduler import AsyncScheduler diff --git a/schedule/async_scheduler.py b/schedule/async_scheduler.py index 943a95a2..35be6cc9 100644 --- a/schedule/async_scheduler.py +++ b/schedule/async_scheduler.py @@ -28,7 +28,7 @@ import inspect import logging -from schedule.scheduler import Scheduler +from schedule.scheduler import Scheduler, BaseScheduler logger = logging.getLogger("async_schedule") @@ -37,7 +37,7 @@ def _inherit_doc(doc): return doc.replace("Scheduler", "AsyncScheduler").replace("job", "async job") -class AsyncScheduler(Scheduler): +class AsyncScheduler(BaseScheduler): __doc__ = _inherit_doc(Scheduler.__doc__) async def run_pending(self) -> None: diff --git a/schedule/job.py b/schedule/job.py index 7d765ac9..8abbdd1e 100644 --- a/schedule/job.py +++ b/schedule/job.py @@ -5,9 +5,14 @@ import logging import random import re -from collections import Hashable +from collections.abc import Hashable from typing import Callable, List, Optional, Set, Union +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from schedule.scheduler import BaseScheduler + logger = logging.getLogger("schedule") @@ -55,7 +60,7 @@ class Job(object): method, which also defines its `interval`. """ - def __init__(self, interval: int, scheduler: "Scheduler" = None): + def __init__(self, interval: int, scheduler: "BaseScheduler" = None): self.interval: int = interval # pause interval * unit between runs self.latest: Optional[int] = None # upper limit to the interval self.job_func: Optional[functools.partial] = None # the job job_func to run @@ -82,7 +87,9 @@ def __init__(self, interval: int, scheduler: "Scheduler" = None): self.cancel_after: Optional[datetime.datetime] = None self.tags: Set[Hashable] = set() # unique set of tags for the job - self.scheduler: Optional[Scheduler] = scheduler # scheduler to register with + self.scheduler: Optional[ + "BaseScheduler" + ] = scheduler # scheduler to register with def __lt__(self, other) -> bool: """ diff --git a/schedule/scheduler.py b/schedule/scheduler.py index f3e70d59..aa49eb27 100644 --- a/schedule/scheduler.py +++ b/schedule/scheduler.py @@ -3,54 +3,24 @@ import datetime import logging import time -from collections import Hashable +from collections.abc import Hashable from typing import List, Optional +from abc import ABC from schedule.job import CancelJob, Job logger = logging.getLogger("schedule") -class Scheduler(object): +class BaseScheduler(ABC): """ - Objects instantiated by the :class:`Scheduler ` are - factories to create jobs, keep record of scheduled jobs and - handle their execution. + The base class that contains the shared functionality: to create jobs, + keep record of scheduled jobs and handle their execution. """ def __init__(self) -> None: self.jobs: List[Job] = [] - def run_pending(self) -> None: - """ - Run all jobs that are scheduled to run. - Please note that it is *intended behavior that run_pending() - does not run missed jobs*. For example, if you've registered a job - that should run every minute and you only call run_pending() - in one hour increments then your job won't be run 60 times in - between but only once. - """ - runnable_jobs = (job for job in self.jobs if job.should_run) - for job in sorted(runnable_jobs): - self._run_job(job) - - def run_all(self, delay_seconds: int = 0) -> None: - """ - Run all jobs regardless if they are scheduled to run or not. - A delay of `delay` seconds is added between each job. This helps - distribute system load generated by the jobs more evenly - over time. - :param delay_seconds: A delay added between every executed job - """ - logger.debug( - "Running *all* %i jobs with %is delay in between", - len(self.jobs), - delay_seconds, - ) - for job in self.jobs[:]: - self._run_job(job) - time.sleep(delay_seconds) - def get_jobs(self, tag: Optional[Hashable] = None) -> List["Job"]: """ Gets scheduled jobs marked with the given tag, or all jobs @@ -103,10 +73,6 @@ def _check_returned_value(self, job, ret): if isinstance(ret, CancelJob) or ret is CancelJob: self.cancel_job(job) - def _run_job(self, job: "Job") -> None: - ret = job.run() - self._check_returned_value(job, ret) - @property def next_run(self) -> Optional[datetime.datetime]: """ @@ -129,3 +95,45 @@ def idle_seconds(self) -> Optional[float]: if not self.next_run: return None return (self.next_run - datetime.datetime.now()).total_seconds() + + +class Scheduler(BaseScheduler): + """ + Objects instantiated by the :class:`Scheduler ` are + factories to create jobs, keep record of scheduled jobs and + handle their execution. + """ + + def run_pending(self) -> None: + """ + Run all jobs that are scheduled to run. + Please note that it is *intended behavior that run_pending() + does not run missed jobs*. For example, if you've registered a job + that should run every minute and you only call run_pending() + in one hour increments then your job won't be run 60 times in + between but only once. + """ + runnable_jobs = (job for job in self.jobs if job.should_run) + for job in sorted(runnable_jobs): + self._run_job(job) + + def run_all(self, delay_seconds: int = 0) -> None: + """ + Run all jobs regardless if they are scheduled to run or not. + A delay of `delay` seconds is added between each job. This helps + distribute system load generated by the jobs more evenly + over time. + :param delay_seconds: A delay added between every executed job + """ + logger.debug( + "Running *all* %i jobs with %is delay in between", + len(self.jobs), + delay_seconds, + ) + for job in self.jobs[:]: + self._run_job(job) + time.sleep(delay_seconds) + + def _run_job(self, job: "Job") -> None: + ret = job.run() + self._check_returned_value(job, ret)