├── setup.cfg ├── MANIFEST.in ├── AUTHORS.rst ├── tox.ini ├── .travis.yml ├── .gitignore ├── setup.py ├── LICENSE.txt ├── HISTORY.rst ├── README.rst ├── schedule └── __init__.py └── test_schedule.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE.txt HISTORY.rst test_schedule.py 2 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | schedule by 2 | ``````````` 3 | 4 | - Daniel Bader 5 | 6 | Patches and Suggestions 7 | ``````````````````````` 8 | 9 | - mrhwick 10 | - mattss 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py33 3 | 4 | [testenv] 5 | deps = 6 | mock 7 | pytest 8 | pytest-cov 9 | pytest-pep8 10 | commands = py.test test_schedule.py --pep8 schedule -v --cov schedule --cov-report term-missing 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.3" 6 | 7 | install: 8 | - pip install pytest-cov --use-mirrors 9 | - pip install pytest-pep8 --use-mirrors 10 | - pip install coveralls --use-mirrors 11 | 12 | script: py.test test_schedule.py --pep8 schedule -v --cov schedule --cov-report term-missing 13 | 14 | after_success: 15 | - coveralls 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | env 38 | env3 39 | __pycache__ 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from distutils.core import setup 4 | 5 | if sys.argv[-1] == 'publish': 6 | os.system('python setup.py sdist upload -r PyPI') 7 | sys.exit() 8 | 9 | setup( 10 | name='schedule', 11 | packages=['schedule'], 12 | version='0.1.10', 13 | description='Job scheduling for humans.', 14 | long_description=(open('README.rst').read() + '\n\n' + 15 | open('HISTORY.rst').read()), 16 | license=open('LICENSE.txt').read(), 17 | author='Daniel Bader', 18 | author_email='mail@dbader.org', 19 | url='https://github.com/dbader/schedule', 20 | download_url='https://github.com/dbader/schedule/tarball/0.1.9', 21 | keywords=[ 22 | 'schedule', 'periodic', 'jobs', 'scheduling', 'clockwork', 23 | 'cron' 24 | ], 25 | classifiers=[ 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Programming Language :: Python :: 2.7', 29 | 'Programming Language :: Python :: 3.3', 30 | 'Natural Language :: English', 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Daniel Bader (http://dbader.org) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | 0.1.10 (2013-06-07) 7 | +++++++++++++++++++ 8 | 9 | - Fixed issue with ``at_time`` jobs not running on the same day the job is created (Thanks to @mattss) 10 | 11 | 0.1.9 (2013-05-27) 12 | ++++++++++++++++++ 13 | 14 | - Added ``schedule.next_run()`` 15 | - Added ``schedule.idle_seconds()`` 16 | - Args passed into ``do()`` are forwarded to the job function at call time 17 | - Increased test coverage to 100% 18 | 19 | 20 | 0.1.8 (2013-05-21) 21 | ++++++++++++++++++ 22 | 23 | - Changed default ``delay_seconds`` for ``schedule.run_all()`` to 0 (from 60) 24 | - Increased test coverage 25 | 26 | 0.1.7 (2013-05-20) 27 | ++++++++++++++++++ 28 | 29 | - API change: renamed ``schedule.run_all_jobs()`` to ``schedule.run_all()`` 30 | - API change: renamed ``schedule.run_pending_jobs()`` to ``schedule.run_pending()`` 31 | - API change: renamed ``schedule.clear_all_jobs()`` to ``schedule.clear()`` 32 | - Added ``schedule.jobs`` 33 | 34 | 0.1.6 (2013-05-20) 35 | ++++++++++++++++++ 36 | 37 | - Fix packaging 38 | - README fixes 39 | 40 | 0.1.4 (2013-05-20) 41 | ++++++++++++++++++ 42 | 43 | - API change: renamed ``schedule.tick()`` to ``schedule.run_pending_jobs()`` 44 | - Updated README and ``setup.py`` packaging 45 | 46 | 0.1.0 (2013-05-19) 47 | ++++++++++++++++++ 48 | 49 | - Initial release 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | schedule 2 | ======== 3 | 4 | 5 | .. image:: https://api.travis-ci.org/dbader/schedule.png 6 | :target: https://travis-ci.org/dbader/schedule 7 | 8 | .. image:: https://coveralls.io/repos/dbader/schedule/badge.png 9 | :target: https://coveralls.io/r/dbader/schedule 10 | 11 | .. image:: https://pypip.in/v/schedule/badge.png 12 | :target: https://pypi.python.org/pypi/schedule 13 | 14 | Python job scheduling for humans. 15 | 16 | An in-process scheduler for periodic jobs that uses the builder pattern 17 | for configuration. Schedule lets you run Python functions (or any other 18 | callable) periodically at pre-determined intervals using a simple, 19 | human-friendly syntax. 20 | 21 | Inspired by `Adam Wiggins' `_ article `"Rethinking Cron" `_ (`Google cache `_) and the `clockwork `_ Ruby module. 22 | 23 | Features 24 | -------- 25 | - A simple to use API for scheduling jobs. 26 | - Very lightweight and no external dependencies. 27 | - Excellent test coverage. 28 | - Works with Python 2.7 and 3.3 29 | 30 | Usage 31 | ----- 32 | 33 | .. code-block:: bash 34 | 35 | $ pip install schedule 36 | 37 | .. code-block:: python 38 | 39 | import schedule 40 | import time 41 | 42 | def job(): 43 | print("I'm working...") 44 | 45 | schedule.every(10).minutes.do(job) 46 | schedule.every().hour.do(job) 47 | schedule.every().day.at("10:30").do(job) 48 | 49 | while True: 50 | schedule.run_pending() 51 | time.sleep(1) 52 | 53 | Meta 54 | ---- 55 | 56 | Daniel Bader – `@dbader_org `_ – mail@dbader.org 57 | 58 | Distributed under the MIT license. See ``LICENSE.txt`` for more information. 59 | 60 | https://github.com/dbader/schedule 61 | -------------------------------------------------------------------------------- /schedule/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python job scheduling for humans. 3 | 4 | An in-process scheduler for periodic jobs that uses the builder pattern 5 | for configuration. Schedule lets you run Python functions (or any other 6 | callable) periodically at pre-determined intervals using a simple, 7 | human-friendly syntax. 8 | 9 | Inspired by Addam Wiggins' article "Rethinking Cron" [1] and the 10 | "clockwork" Ruby module [2][3]. 11 | 12 | Features: 13 | - A simple to use API for scheduling jobs. 14 | - Very lightweight and no external dependencies. 15 | - Excellent test coverage. 16 | - Works with Python 2.7 and 3.3 17 | 18 | Usage: 19 | >>> import schedule 20 | >>> import time 21 | 22 | >>> def job(message='stuff'): 23 | >>> print("I'm working on:", message) 24 | 25 | >>> schedule.every(10).minutes.do(job) 26 | >>> schedule.every().hour.do(job, message='things') 27 | >>> schedule.every().day.at("10:30").do(job) 28 | 29 | >>> while True: 30 | >>> schedule.run_pending() 31 | >>> time.sleep(1) 32 | 33 | [1] http://adam.heroku.com/past/2010/4/13/rethinking_cron/ 34 | [2] https://github.com/tomykaira/clockwork 35 | [3] http://adam.heroku.com/past/2010/6/30/replace_cron_with_clockwork/ 36 | """ 37 | import datetime 38 | import functools 39 | import logging 40 | import time 41 | import threading 42 | 43 | logger = logging.getLogger('schedule') 44 | 45 | 46 | class Scheduler(object): 47 | def __init__(self): 48 | self.jobs = [] 49 | 50 | def run_pending(self): 51 | """Run all jobs that are scheduled to run. 52 | 53 | Please note that it is *intended behavior that tick() does not 54 | run missed jobs*. For example, if you've registered a job that 55 | should run every minute and you only call tick() in one hour 56 | increments then your job won't be run 60 times in between but 57 | only once. 58 | """ 59 | runnable_jobs = (job for job in self.jobs if job.should_run) 60 | for job in sorted(runnable_jobs): 61 | job.run() 62 | 63 | def run_continuously(self, interval=1): 64 | """Continuously run, while executing pending jobs at each elapsed 65 | time interval. 66 | 67 | @return cease_continuous_run: threading.Event which can be set to 68 | cease continuous run. 69 | 70 | Please note that it is *intended behavior that run_continuously() 71 | does not run missed jobs*. For example, if you've registered a job 72 | that should run every minute and you set a continuous run interval 73 | of one hour then your job won't be run 60 times at each interval but 74 | only once. 75 | """ 76 | cease_continuous_run = threading.Event() 77 | 78 | class ScheduleThread(threading.Thread): 79 | @classmethod 80 | def run(cls): 81 | while not cease_continuous_run.is_set(): 82 | self.run_pending() 83 | time.sleep(interval) 84 | 85 | continuous_thread = ScheduleThread() 86 | continuous_thread.start() 87 | return cease_continuous_run 88 | 89 | def run_all(self, delay_seconds=0): 90 | """Run all jobs regardless if they are scheduled to run or not. 91 | 92 | A delay of `delay` seconds is added between each job. This helps 93 | distribute system load generated by the jobs more evenly 94 | over time.""" 95 | logger.info('Running *all* %i jobs with %is delay inbetween', 96 | len(self.jobs), delay_seconds) 97 | for job in self.jobs: 98 | job.run() 99 | time.sleep(delay_seconds) 100 | 101 | def clear(self): 102 | """Deletes all scheduled jobs.""" 103 | del self.jobs[:] 104 | 105 | def every(self, interval=1): 106 | """Schedule a new periodic job.""" 107 | job = Job(interval) 108 | self.jobs.append(job) 109 | return job 110 | 111 | @property 112 | def next_run(self): 113 | """Datetime when the next job should run.""" 114 | return min(self.jobs).next_run 115 | 116 | @property 117 | def idle_seconds(self): 118 | """Number of seconds until `next_run`.""" 119 | return (self.next_run - datetime.datetime.now()).total_seconds() 120 | 121 | 122 | class Job(object): 123 | """A periodic job as used by `Scheduler`.""" 124 | def __init__(self, interval): 125 | self.interval = interval # pause interval * unit between runs 126 | self.job_func = None # the job job_func to run 127 | self.unit = None # time units, e.g. 'minutes', 'hours', ... 128 | self.at_time = None # optional time at which this job runs 129 | self.last_run = None # datetime of the last run 130 | self.next_run = None # datetime of the next run 131 | self.period = None # timedelta between runs, only valid for 132 | 133 | def __lt__(self, other): 134 | """PeriodicJobs are sortable based on the scheduled time 135 | they run next.""" 136 | return self.next_run < other.next_run 137 | 138 | def __repr__(self): 139 | def format_time(t): 140 | return t.strftime("%Y-%m-%d %H:%M:%S") if t else '[never]' 141 | 142 | timestats = '(last run: %s, next run: %s)' % ( 143 | format_time(self.last_run), format_time(self.next_run)) 144 | 145 | job_func_name = self.job_func.__name__ 146 | args = [repr(x) for x in self.job_func.args] 147 | kwargs = ['%s=%s' % (k, repr(v)) 148 | for k, v in self.job_func.keywords.items()] 149 | call_repr = job_func_name + '(' + ', '.join(args + kwargs) + ')' 150 | 151 | if self.at_time is not None: 152 | return 'Every %s %s at %s do %s %s' % ( 153 | self.interval, 154 | self.unit[:-1] if self.interval == 1 else self.unit, 155 | self.at_time, call_repr, timestats) 156 | else: 157 | return 'Every %s %s do %s %s' % ( 158 | self.interval, 159 | self.unit[:-1] if self.interval == 1 else self.unit, 160 | call_repr, timestats) 161 | 162 | @property 163 | def second(self): 164 | assert self.interval == 1 165 | return self.seconds 166 | 167 | @property 168 | def seconds(self): 169 | self.unit = 'seconds' 170 | return self 171 | 172 | @property 173 | def minute(self): 174 | assert self.interval == 1 175 | return self.minutes 176 | 177 | @property 178 | def minutes(self): 179 | self.unit = 'minutes' 180 | return self 181 | 182 | @property 183 | def hour(self): 184 | assert self.interval == 1 185 | return self.hours 186 | 187 | @property 188 | def hours(self): 189 | self.unit = 'hours' 190 | return self 191 | 192 | @property 193 | def day(self): 194 | assert self.interval == 1 195 | return self.days 196 | 197 | @property 198 | def days(self): 199 | self.unit = 'days' 200 | return self 201 | 202 | @property 203 | def week(self): 204 | assert self.interval == 1 205 | return self.weeks 206 | 207 | @property 208 | def weeks(self): 209 | self.unit = 'weeks' 210 | return self 211 | 212 | def at(self, time_str): 213 | """Schedule the job every day at a specific time. 214 | 215 | Calling this is only valid for jobs scheduled to run every 216 | N day(s). 217 | """ 218 | assert self.unit == 'days' 219 | hour, minute = [int(t) for t in time_str.split(':')] 220 | assert 0 <= hour <= 23 221 | assert 0 <= minute <= 59 222 | self.at_time = datetime.time(hour, minute) 223 | return self 224 | 225 | def do(self, job_func, *args, **kwargs): 226 | """Specifies the job_func that should be called every time the 227 | job runs. 228 | 229 | Any additional arguments are passed on to job_func when 230 | the job runs. 231 | """ 232 | self.job_func = functools.partial(job_func, *args, **kwargs) 233 | functools.update_wrapper(self.job_func, job_func) 234 | self._schedule_next_run() 235 | return self 236 | 237 | @property 238 | def should_run(self): 239 | """True if the job should be run now.""" 240 | return datetime.datetime.now() >= self.next_run 241 | 242 | def run(self): 243 | """Run the job and immediately reschedule it.""" 244 | logger.info('Running job %s', self) 245 | self.job_func() 246 | self.last_run = datetime.datetime.now() 247 | self._schedule_next_run() 248 | 249 | def _schedule_next_run(self): 250 | """Compute the instant when this job should run next.""" 251 | # Allow *, ** magic temporarily: 252 | # pylint: disable=W0142 253 | assert self.unit in ('seconds', 'minutes', 'hours', 'days', 'weeks') 254 | self.period = datetime.timedelta(**{self.unit: self.interval}) 255 | self.next_run = datetime.datetime.now() + self.period 256 | if self.at_time: 257 | assert self.unit == 'days' 258 | self.next_run = self.next_run.replace(hour=self.at_time.hour, 259 | minute=self.at_time.minute, 260 | second=self.at_time.second, 261 | microsecond=0) 262 | # If we are running for the first time, make sure we run 263 | # at the specified time *today* as well 264 | if (not self.last_run and 265 | self.at_time > datetime.datetime.now().time()): 266 | self.next_run = self.next_run - datetime.timedelta(days=1) 267 | 268 | 269 | # The following methods are shortcuts for not having to 270 | # create a Scheduler instance: 271 | 272 | default_scheduler = Scheduler() 273 | jobs = default_scheduler.jobs # todo: should this be a copy, e.g. jobs()? 274 | 275 | 276 | def every(interval=1): 277 | """Schedule a new periodic job.""" 278 | return default_scheduler.every(interval) 279 | 280 | 281 | def run_continuously(interval=1): 282 | """Continuously run, while executing pending jobs at each elapsed 283 | time interval. 284 | 285 | @return cease_continuous_run: threading.Event which can be set to 286 | cease continuous run. 287 | 288 | Please note that it is *intended behavior that run_continuously() 289 | does not run missed jobs*. For example, if you've registered a job 290 | that should run every minute and you set a continuous run interval 291 | of one hour then your job won't be run 60 times at each interval but 292 | only once. 293 | """ 294 | return default_scheduler.run_continuously(interval) 295 | 296 | 297 | def run_pending(): 298 | """Run all jobs that are scheduled to run. 299 | 300 | Please note that it is *intended behavior that run_pending() 301 | does not run missed jobs*. For example, if you've registered a job 302 | that should run every minute and you only call run_pending() 303 | in one hour increments then your job won't be run 60 times in 304 | between but only once. 305 | """ 306 | default_scheduler.run_pending() 307 | 308 | 309 | def run_all(delay_seconds=0): 310 | """Run all jobs regardless if they are scheduled to run or not. 311 | 312 | A delay of `delay` seconds is added between each job. This can help 313 | to distribute the system load generated by the jobs more evenly over 314 | time.""" 315 | default_scheduler.run_all(delay_seconds=delay_seconds) 316 | 317 | 318 | def clear(): 319 | """Deletes all scheduled jobs.""" 320 | default_scheduler.clear() 321 | 322 | 323 | def next_run(): 324 | """Datetime when the next job should run.""" 325 | return default_scheduler.next_run 326 | 327 | 328 | def idle_seconds(): 329 | """Number of seconds until `next_run`.""" 330 | return default_scheduler.idle_seconds 331 | -------------------------------------------------------------------------------- /test_schedule.py: -------------------------------------------------------------------------------- 1 | """Unit tests for schedule.py""" 2 | import unittest 3 | import mock 4 | import datetime 5 | import time 6 | 7 | # Silence "missing docstring", "method could be a function", 8 | # "class already defined", and "too many public methods" messages: 9 | # pylint: disable-msg=R0201,C0111,E0102,R0904,R0901 10 | 11 | import schedule 12 | from schedule import every 13 | 14 | 15 | def make_mock_job(name=None): 16 | job = mock.Mock() 17 | job.__name__ = name or 'job' 18 | return job 19 | 20 | 21 | class SchedulerTests(unittest.TestCase): 22 | def setUp(self): 23 | schedule.clear() 24 | 25 | def test_time_units(self): 26 | assert every().seconds.unit == 'seconds' 27 | assert every().minutes.unit == 'minutes' 28 | assert every().hours.unit == 'hours' 29 | assert every().days.unit == 'days' 30 | assert every().weeks.unit == 'weeks' 31 | 32 | def test_singular_time_units_match_plural_units(self): 33 | assert every().second.unit == every().seconds.unit 34 | assert every().minute.unit == every().minutes.unit 35 | assert every().hour.unit == every().hours.unit 36 | assert every().day.unit == every().days.unit 37 | assert every().week.unit == every().weeks.unit 38 | 39 | def test_at_time(self): 40 | mock_job = make_mock_job() 41 | assert every().day.at('10:30').do(mock_job).next_run.hour == 10 42 | assert every().day.at('10:30').do(mock_job).next_run.minute == 30 43 | 44 | def test_next_run_time(self): 45 | # Monkey-patch datetime.datetime to get predictable (=testable) results 46 | class MockDate(datetime.datetime): 47 | @classmethod 48 | def today(cls): 49 | return cls(2010, 1, 6) 50 | 51 | @classmethod 52 | def now(cls): 53 | return cls(2010, 1, 6, 12, 15) 54 | original_datetime = datetime.datetime 55 | datetime.datetime = MockDate 56 | 57 | mock_job = make_mock_job() 58 | assert every().minute.do(mock_job).next_run.minute == 16 59 | assert every(5).minutes.do(mock_job).next_run.minute == 20 60 | assert every().hour.do(mock_job).next_run.hour == 13 61 | assert every().day.do(mock_job).next_run.day == 7 62 | assert every().day.at('09:00').do(mock_job).next_run.day == 7 63 | assert every().day.at('12:30').do(mock_job).next_run.day == 6 64 | assert every().week.do(mock_job).next_run.day == 13 65 | 66 | datetime.datetime = original_datetime 67 | 68 | def test_run_all(self): 69 | mock_job = make_mock_job() 70 | every().minute.do(mock_job) 71 | every().hour.do(mock_job) 72 | every().day.at('11:00').do(mock_job) 73 | schedule.run_all() 74 | assert mock_job.call_count == 3 75 | 76 | def test_job_func_args_are_passed_on(self): 77 | mock_job = make_mock_job() 78 | every().second.do(mock_job, 1, 2, 'three', foo=23, bar={}) 79 | schedule.run_all() 80 | mock_job.assert_called_once_with(1, 2, 'three', foo=23, bar={}) 81 | 82 | def test_to_string(self): 83 | def job_fun(): 84 | pass 85 | s = str(every().minute.do(job_fun, 'foo', bar=23)) 86 | assert 'job_fun' in s 87 | assert 'foo' in s 88 | assert 'bar=23' in s 89 | assert len(str(every().minute.do(lambda: 1))) > 1 90 | assert len(str(every().day.at("10:30").do(lambda: 1))) > 1 91 | 92 | def test_run_pending(self): 93 | """Check that run_pending() runs pending jobs. 94 | We do this by overriding datetime.datetime with mock objects 95 | that represent increasing system times. 96 | 97 | Please note that it is *intended behavior that run_pending() does not 98 | run missed jobs*. For example, if you've registered a job that 99 | should run every minute and you only call run_pending() in one hour 100 | increments then your job won't be run 60 times in between but 101 | only once. 102 | """ 103 | # Monkey-patch datetime.datetime to get predictable (=testable) results 104 | class MockDate(datetime.datetime): 105 | @classmethod 106 | def today(cls): 107 | return cls(2010, 1, 6) 108 | 109 | @classmethod 110 | def now(cls): 111 | return cls(2010, 1, 6, 12, 15) 112 | original_datetime = datetime.datetime 113 | datetime.datetime = MockDate 114 | 115 | mock_job = make_mock_job() 116 | every().minute.do(mock_job) 117 | every().hour.do(mock_job) 118 | every().day.do(mock_job) 119 | 120 | schedule.run_pending() 121 | assert mock_job.call_count == 0 122 | 123 | # Minutely 124 | class MockDate(datetime.datetime): 125 | @classmethod 126 | def today(cls): 127 | return cls(2010, 1, 6) 128 | 129 | @classmethod 130 | def now(cls): 131 | return cls(2010, 1, 6, 12, 16) 132 | datetime.datetime = MockDate 133 | schedule.run_pending() 134 | assert mock_job.call_count == 1 135 | 136 | # Minutely, hourly 137 | class MockDate(datetime.datetime): 138 | @classmethod 139 | def today(cls): 140 | return cls(2010, 1, 6) 141 | 142 | @classmethod 143 | def now(cls): 144 | return cls(2010, 1, 6, 13, 16) 145 | datetime.datetime = MockDate 146 | 147 | mock_job.reset_mock() 148 | schedule.run_pending() 149 | assert mock_job.call_count == 2 150 | 151 | # Minutely, hourly, daily 152 | class MockDate(datetime.datetime): 153 | @classmethod 154 | def today(cls): 155 | return cls(2010, 1, 7) 156 | 157 | @classmethod 158 | def now(cls): 159 | return cls(2010, 1, 7, 13, 16) 160 | datetime.datetime = MockDate 161 | 162 | mock_job.reset_mock() 163 | schedule.run_pending() 164 | assert mock_job.call_count == 3 165 | 166 | datetime.datetime = original_datetime 167 | 168 | def test_run_every_n_days_at_specific_time(self): 169 | class MockDate(datetime.datetime): 170 | @classmethod 171 | def today(cls): 172 | return cls(2010, 1, 6) 173 | 174 | @classmethod 175 | def now(cls): 176 | return cls(2010, 1, 6, 13, 16) 177 | original_datetime = datetime.datetime 178 | datetime.datetime = MockDate 179 | 180 | mock_job = make_mock_job() 181 | every(2).days.at("11:30").do(mock_job) 182 | 183 | schedule.run_pending() 184 | assert mock_job.call_count == 0 185 | 186 | class MockDate(datetime.datetime): 187 | @classmethod 188 | def today(cls): 189 | return cls(2010, 1, 7) 190 | 191 | @classmethod 192 | def now(cls): 193 | return cls(2010, 1, 7, 13, 16) 194 | datetime.datetime = MockDate 195 | 196 | schedule.run_pending() 197 | assert mock_job.call_count == 0 198 | 199 | class MockDate(datetime.datetime): 200 | @classmethod 201 | def today(cls): 202 | return cls(2010, 1, 8) 203 | 204 | @classmethod 205 | def now(cls): 206 | return cls(2010, 1, 8, 13, 16) 207 | datetime.datetime = MockDate 208 | 209 | schedule.run_pending() 210 | assert mock_job.call_count == 1 211 | 212 | class MockDate(datetime.datetime): 213 | @classmethod 214 | def today(cls): 215 | return cls(2010, 1, 10) 216 | 217 | @classmethod 218 | def now(cls): 219 | return cls(2010, 1, 10, 13, 16) 220 | datetime.datetime = MockDate 221 | 222 | schedule.run_pending() 223 | assert mock_job.call_count == 2 224 | 225 | datetime.datetime = original_datetime 226 | 227 | def test_next_run_property(self): 228 | class MockDate(datetime.datetime): 229 | @classmethod 230 | def today(cls): 231 | return cls(2010, 1, 6) 232 | 233 | @classmethod 234 | def now(cls): 235 | return cls(2010, 1, 6, 13, 16) 236 | original_datetime = datetime.datetime 237 | datetime.datetime = MockDate 238 | 239 | hourly_job = make_mock_job('hourly') 240 | daily_job = make_mock_job('daily') 241 | every().day.do(daily_job) 242 | every().hour.do(hourly_job) 243 | assert len(schedule.jobs) == 2 244 | # Make sure the hourly job is first 245 | assert schedule.next_run() == original_datetime(2010, 1, 6, 14, 16) 246 | assert schedule.idle_seconds() == 60 * 60 247 | 248 | datetime.datetime = original_datetime 249 | 250 | def test_run_continuously(self): 251 | """Check that run_continuously() runs pending jobs. 252 | We do this by overriding datetime.datetime with mock objects 253 | that represent increasing system times. 254 | 255 | Please note that it is *intended behavior that run_continuously() 256 | does not run missed jobs*. For example, if you've registered a job 257 | that should run every minute and you set a continuous run interval 258 | of one hour then your job won't be run 60 times at each interval but 259 | only once. 260 | """ 261 | # Monkey-patch datetime.datetime to get predictable (=testable) results 262 | class MockDate(datetime.datetime): 263 | @classmethod 264 | def today(cls): 265 | return cls(2010, 1, 6) 266 | 267 | @classmethod 268 | def now(cls): 269 | return cls(2010, 1, 6, 12, 15, 0) 270 | original_datetime = datetime.datetime 271 | datetime.datetime = MockDate 272 | 273 | mock_job = make_mock_job() 274 | 275 | # Secondly Tests 276 | # Initialize everything. 277 | schedule.clear() 278 | mock_job.reset_mock() 279 | every().second.do(mock_job) 280 | 281 | # Start a new continuous run thread. 282 | stop_thread_flag = schedule.run_continuously(0) 283 | # Allow a small time for separate thread to register time stamps. 284 | time.sleep(0.001) 285 | 286 | assert mock_job.call_count == 0 287 | 288 | # Secondly first second. 289 | class MockDate(datetime.datetime): 290 | @classmethod 291 | def today(cls): 292 | return cls(2010, 1, 6) 293 | 294 | @classmethod 295 | def now(cls): 296 | return cls(2010, 1, 6, 12, 15, 1) 297 | mock_job.reset_mock() 298 | datetime.datetime = MockDate 299 | # Allow a small time for separate thread to register time stamps. 300 | time.sleep(0.001) 301 | 302 | assert mock_job.call_count == 1 303 | 304 | # Secondly second second. 305 | class MockDate(datetime.datetime): 306 | @classmethod 307 | def today(cls): 308 | return cls(2010, 1, 6) 309 | 310 | @classmethod 311 | def now(cls): 312 | return cls(2010, 1, 6, 12, 15, 2) 313 | datetime.datetime = MockDate 314 | # Allow a small time for separate thread to register time stamps. 315 | time.sleep(0.001) 316 | 317 | assert mock_job.call_count == 2 318 | 319 | # Minutely Tests 320 | # (Re)Initialize everything. 321 | schedule.clear() 322 | mock_job.reset_mock() 323 | stop_thread_flag.set() 324 | every().minute.do(mock_job) 325 | 326 | # Start a new continuous run thread. 327 | stop_thread_flag = schedule.run_continuously(0) 328 | # Allow a small time for separate thread to register time stamps. 329 | time.sleep(0.001) 330 | 331 | assert mock_job.call_count == 0 332 | 333 | # Minutely first minute. 334 | class MockDate(datetime.datetime): 335 | @classmethod 336 | def today(cls): 337 | return cls(2010, 1, 6) 338 | 339 | @classmethod 340 | def now(cls): 341 | return cls(2010, 1, 6, 12, 16, 2) 342 | mock_job.reset_mock() 343 | datetime.datetime = MockDate 344 | # Allow a small time for separate thread to register time stamps. 345 | time.sleep(0.001) 346 | 347 | assert mock_job.call_count == 1 348 | 349 | # Minutely second minute. 350 | class MockDate(datetime.datetime): 351 | @classmethod 352 | def today(cls): 353 | return cls(2010, 1, 6) 354 | 355 | @classmethod 356 | def now(cls): 357 | return cls(2010, 1, 6, 12, 17, 2) 358 | datetime.datetime = MockDate 359 | # Allow a small time for separate thread to register time stamps. 360 | time.sleep(0.001) 361 | 362 | assert mock_job.call_count == 2 363 | 364 | # Hourly Tests 365 | # (Re)Initialize everything. 366 | schedule.clear() 367 | mock_job.reset_mock() 368 | stop_thread_flag.set() 369 | every().hour.do(mock_job) 370 | 371 | # Start a new continuous run thread. 372 | stop_thread_flag = schedule.run_continuously(0) 373 | # Allow a small time for separate thread to register time stamps. 374 | time.sleep(0.001) 375 | 376 | assert mock_job.call_count == 0 377 | 378 | # Hourly first hour. 379 | class MockDate(datetime.datetime): 380 | @classmethod 381 | def today(cls): 382 | return cls(2010, 1, 6) 383 | 384 | @classmethod 385 | def now(cls): 386 | return cls(2010, 1, 6, 13, 17, 2) 387 | mock_job.reset_mock() 388 | datetime.datetime = MockDate 389 | # Allow a small time for separate thread to register time stamps. 390 | time.sleep(0.001) 391 | 392 | assert mock_job.call_count == 1 393 | 394 | # Hourly second hour. 395 | class MockDate(datetime.datetime): 396 | @classmethod 397 | def today(cls): 398 | return cls(2010, 1, 6) 399 | 400 | @classmethod 401 | def now(cls): 402 | return cls(2010, 1, 6, 14, 17, 2) 403 | datetime.datetime = MockDate 404 | # Allow a small time for separate thread to register time stamps. 405 | time.sleep(0.001) 406 | 407 | assert mock_job.call_count == 2 408 | 409 | # Daily Tests 410 | # (Re)Initialize everything. 411 | schedule.clear() 412 | mock_job.reset_mock() 413 | stop_thread_flag.set() 414 | every().day.do(mock_job) 415 | 416 | # Start a new continuous run thread. 417 | stop_thread_flag = schedule.run_continuously(0) 418 | # Allow a small time for separate thread to register time stamps. 419 | time.sleep(0.001) 420 | 421 | assert mock_job.call_count == 0 422 | 423 | # Daily first day. 424 | class MockDate(datetime.datetime): 425 | @classmethod 426 | def today(cls): 427 | return cls(2010, 1, 6) 428 | 429 | @classmethod 430 | def now(cls): 431 | return cls(2010, 1, 7, 14, 17, 2) 432 | mock_job.reset_mock() 433 | datetime.datetime = MockDate 434 | # Allow a small time for separate thread to register time stamps. 435 | time.sleep(0.001) 436 | 437 | assert mock_job.call_count == 1 438 | 439 | # Daily second day. 440 | class MockDate(datetime.datetime): 441 | @classmethod 442 | def today(cls): 443 | return cls(2010, 1, 6) 444 | 445 | @classmethod 446 | def now(cls): 447 | return cls(2010, 1, 8, 14, 17, 2) 448 | datetime.datetime = MockDate 449 | # Allow a small time for separate thread to register time stamps. 450 | time.sleep(0.001) 451 | 452 | assert mock_job.call_count == 2 453 | 454 | schedule.clear() 455 | mock_job.reset_mock() 456 | stop_thread_flag.set() 457 | datetime.datetime = original_datetime 458 | --------------------------------------------------------------------------------