├── .gitignore ├── .python-version ├── .travis.yml ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── dev-requirements.txt ├── donkey ├── __init__.py ├── compat.py ├── job.py └── worker.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_job.py /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | *.egg-info/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 2.7.9 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | before_install: 5 | - "pip install -r requirements.txt" 6 | - "pip install -r dev-requirements.txt" 7 | script: "py.test" 8 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcho/donkey/8f9d8c9649a68a09d30999b77bcced38c4173264/CHANGES.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 hbc 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGES.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Donkey [WIP] 2 | 3 | [![Build Status](https://travis-ci.org/bcho/donkey.svg)](https://travis-ci.org/bcho/donkey) 4 | 5 | A simple cron-like library for executing scheduled jobs. 6 | 7 | 8 | Donkey is inspired by [Cron][cron-go]. 9 | 10 | [cron-go]: https://github.com/robfig/cron 11 | 12 | 13 | ```python 14 | from datetime import datetime 15 | from donkey import JobQueue, Worker 16 | 17 | 18 | q = JobQueue() 19 | 20 | 21 | @q.job(3) 22 | def this_job_runs_every_3_seconds(): 23 | print('Fuzz', datetime.now()) 24 | 25 | 26 | @q.job(5) 27 | def this_job_runs_every_5_seconds(): 28 | print('Buzz', datetime.now()) 29 | 30 | 31 | Worker().run(q) 32 | # Fuzz 2015-02-03 16:41:01.408136 33 | # Buzz 2015-02-03 16:41:03.404123 34 | # Fuzz 2015-02-03 16:41:04.406813 35 | # Fuzz 2015-02-03 16:41:07.408426 36 | # Buzz 2015-02-03 16:41:08.406851 37 | # Fuzz 2015-02-03 16:41:10.408415 38 | # Fuzz 2015-02-03 16:41:13.403260 39 | # Buzz 2015-02-03 16:41:13.403319 40 | ``` 41 | 42 | ## TODO 43 | 44 | - [x] tests. 45 | - [ ] add jobs at run time. 46 | - [ ] job states & stats (see [rq][rq]). 47 | - [ ] other backend (namely `thread`, `stackless`) support. 48 | 49 | 50 | [rq]: http://python-rq.org/ 51 | 52 | 53 | ## License 54 | 55 | [MIT](LICENSE) 56 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /donkey/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | ''' 4 | donkey 5 | ~~~~~~~ 6 | 7 | A simple cron-like library for executing scheduled jobs. 8 | ''' 9 | 10 | from .worker import Worker 11 | from .job import Schedule, Job, JobQueue 12 | 13 | 14 | __all__ = ['Worker', 'Schedule', 'Job', 'JobQueue'] 15 | 16 | 17 | __version__ = '0.0.1' 18 | -------------------------------------------------------------------------------- /donkey/compat.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | ''' 4 | donkey.compat 5 | ~~~~~~~~~~~~~ 6 | 7 | Compatibility. 8 | ''' 9 | 10 | import gevent 11 | 12 | 13 | def spawn(func, *args, **kwargs): 14 | '''Spawn a function. 15 | 16 | By now only `gevent` is supported. 17 | 18 | :param func: function to be ran. 19 | ''' 20 | return gevent.spawn(func, *args, **kwargs) 21 | 22 | 23 | def sleep(seconds): 24 | '''Sleep for some times. 25 | 26 | :param seconds: seonds you want to sleep. 27 | ''' 28 | return gevent.sleep(seconds) 29 | -------------------------------------------------------------------------------- /donkey/job.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | ''' 4 | donkey.job 5 | ~~~~~~~~~~ 6 | 7 | Donkey job data structure. 8 | ''' 9 | 10 | from .compat import spawn 11 | 12 | 13 | class Schedule(object): 14 | '''Job schedule defination. 15 | 16 | :param interval: execute interval (in seconds). 17 | ''' 18 | 19 | def __init__(self, interval): 20 | # TODO support fuzzy language / crontab format 21 | self.interval = int(interval) 22 | 23 | def next(self, now): 24 | '''Calculate next runnable time (in unix timestamp). 25 | 26 | :param now: current unix timestamp. 27 | ''' 28 | return now + self.interval 29 | 30 | 31 | class Job(object): 32 | '''Scheduled job. 33 | 34 | TODO job state 35 | 36 | :param exec_: the actual job. 37 | :param schedule: a :class:`donkey.Schedule` instance. 38 | ''' 39 | 40 | def __init__(self, exec_, schedule): 41 | self.exec_ = exec_ 42 | self.schedule = schedule 43 | 44 | self._last_run_at = None 45 | self._next_run_at = None 46 | 47 | def run(self): 48 | '''Execute the job''' 49 | return self.exec_() 50 | 51 | def reschedule(self, now): 52 | '''Reschedule the job base on current timestamp. 53 | 54 | :param now: current unix timestamp. 55 | ''' 56 | self._last_run_at = self.next_run_at 57 | self._next_run_at = self.schedule.next(now) 58 | 59 | @property 60 | def last_run_at(self): 61 | '''Last ran timestamp.''' 62 | return self._last_run_at 63 | 64 | @property 65 | def next_run_at(self): 66 | '''Next ran timestamp.''' 67 | return self._next_run_at 68 | 69 | 70 | class JobQueue(object): 71 | 72 | def __init__(self): 73 | # TODO race condition 74 | self.jobs = [] 75 | 76 | def add(self, job): 77 | '''Add a job to the queue. 78 | 79 | :param job: a :class:`donkey.Job` instance. 80 | ''' 81 | self.jobs.append(job) 82 | 83 | def job(self, interval): 84 | '''Return a decorator for adding a function as job: 85 | 86 | @queue.job(300) 87 | def my_job(): 88 | print('I am working...') 89 | 90 | :param interval: execute interval (in seconds). 91 | ''' 92 | def wrapper(func): 93 | job = Job(func, Schedule(interval)) 94 | 95 | return self.add(job) 96 | 97 | return wrapper 98 | 99 | def get_runnable_jobs(self): 100 | '''Get runnable jobs.''' 101 | # Sort by next run time, sooner the better. 102 | self.jobs.sort(key=lambda j: j.next_run_at) 103 | 104 | return self.jobs 105 | 106 | @property 107 | def next_run_at(self): 108 | '''Get next running unix timestamp.''' 109 | jobs = self.get_runnable_jobs() 110 | 111 | if not jobs: 112 | return None 113 | return jobs[0].next_run_at 114 | 115 | def run_jobs(self, now): 116 | '''Execute jobs run at this moment. 117 | 118 | :param now: current unix timestamp. 119 | ''' 120 | for job in self.jobs: 121 | if job.next_run_at != now: 122 | return 123 | spawn(job.run) 124 | job.reschedule(now) 125 | 126 | def reschedule_jobs(self, now): 127 | '''Reschedule all jobs base on current timestamp. 128 | 129 | :param now: current unix timestamp. 130 | ''' 131 | for job in self.jobs: 132 | job.reschedule(now) 133 | -------------------------------------------------------------------------------- /donkey/worker.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | ''' 4 | donkey.worker 5 | ~~~~~~~~~~~~~ 6 | 7 | Donkey worker. 8 | ''' 9 | 10 | from time import time as get_now_timestamp 11 | 12 | from .compat import sleep 13 | 14 | 15 | A_LONG_TIME = 10 * 365 * 24 * 3600 # 10 years 16 | 17 | 18 | class Worker(object): 19 | 20 | def run(self, queue): 21 | '''Let the worker run. 22 | 23 | :param queue: :class:`donkey.JobQueue` instance. 24 | ''' 25 | queue.reschedule_jobs(get_now_timestamp()) 26 | 27 | while True: 28 | effective = queue.next_run_at 29 | if effective: 30 | # Wait until next schedule comes... 31 | sleep(effective - get_now_timestamp()) 32 | queue.run_jobs(effective) 33 | else: 34 | # TODO support add job in the runtime. 35 | sleep(A_LONG_TIME) 36 | queue.reschedule_jobs(get_now_timestamp()) 37 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | gevent 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from setuptools import setup, find_packages 4 | 5 | README = open('README.md').read() 6 | CHANGES = open('CHANGES.md').read() 7 | 8 | 9 | setup( 10 | name='donkey', 11 | version='0.0.2', 12 | 13 | author='hbc', 14 | author_email='bcxxxxxx@gmail.com', 15 | url='https://github.com/bcho/donkey', 16 | 17 | description='A simple cron-like library for executing scheduled jobs.', 18 | long_description='\n'.join((README, CHANGES)), 19 | license='MIT', 20 | 21 | packages=find_packages(exclude=['tests']), 22 | include_package_data=True, 23 | install_requires=[ 24 | 'gevent', 25 | ], 26 | 27 | classifiers=[ 28 | 'Development Status :: 4 - Beta', 29 | 'Operating System :: POSIX :: Linux', 30 | 'Programming Language :: Python :: 3', 31 | 'Programming Language :: Python :: 2', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | 'Intended Audience :: Developers', 34 | 'License :: OSI Approved :: MIT License', 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bcho/donkey/8f9d8c9649a68a09d30999b77bcced38c4173264/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_job.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from donkey.job import Schedule, Job, JobQueue 4 | 5 | 6 | def test_schedule(): 7 | interval = 30 8 | sched = Schedule(interval) 9 | 10 | assert sched.next(0) == 0 + interval 11 | assert sched.next(123) == 123 + interval 12 | 13 | 14 | def test_job_run(): 15 | expected_rv = 42 16 | job = Job(lambda: expected_rv, Schedule(30)) 17 | 18 | assert job.run() == expected_rv 19 | 20 | 21 | def test_job_run_stats(): 22 | interval = 30 23 | job = Job(lambda: 'foobar', Schedule(interval)) 24 | 25 | # Before first run 26 | assert job.last_run_at is None 27 | assert job.next_run_at is None 28 | 29 | job.reschedule(0) 30 | 31 | # After first run 32 | assert job.last_run_at is None 33 | assert job.next_run_at == 0 + interval 34 | 35 | job.reschedule(interval) 36 | 37 | # After second run 38 | assert job.last_run_at == 0 + interval 39 | assert job.next_run_at == interval + interval 40 | 41 | 42 | def test_job_queue_add(): 43 | q = JobQueue() 44 | 45 | q.add(Job(lambda: 'nop', Schedule(30))) 46 | 47 | assert len(q.get_runnable_jobs()) == 1 48 | 49 | 50 | def test_job_queue_job_decorator(): 51 | q = JobQueue() 52 | 53 | @q.job(30) 54 | def nop(): 55 | pass 56 | 57 | assert len(q.get_runnable_jobs()) == 1 58 | 59 | 60 | def test_job_queue_next_run_at(): 61 | q = JobQueue() 62 | 63 | # Should return ``None`` when no available job. 64 | assert q.next_run_at is None 65 | 66 | q.job(30)(lambda: 'nop') 67 | q.reschedule_jobs(0) # TODO refine reschedule jobs? 68 | 69 | assert q.next_run_at is not None 70 | --------------------------------------------------------------------------------