├── .gitignore ├── LICENSE.MD ├── README.md ├── docs ├── images │ ├── logo.png │ └── logo3.png └── index.md ├── easyschedule ├── __init__.py ├── cron.py └── scheduler.py ├── images └── logo.png ├── mkdocs.yml ├── nextbuild.py ├── setup.py └── tests └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE.MD: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joshua Jamison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./docs/images/logo3.png) 2 | 3 |

An asynchronus scheduler for python tasks, which uses Cron schedules for precise scheduling recurring tasks

4 | 5 | [![Documentation Status](https://readthedocs.org/projects/easyschedule/badge/?version=latest)](https://easyschedule.readthedocs.io/en/latest/?badge=latest) [![PyPI version](https://badge.fury.io/py/easyschedule.svg)](https://pypi.org/project/easyschedule/) 6 | # 7 | 8 | ## Documenation 9 | [https://easyschedule.readthedocs.io/en/latest/](https://easyschedule.readthedocs.io/en/latest/) 10 | 11 | # 12 | 13 | ## Get Started 14 | ```bash 15 | pip install easyschedule 16 | ``` 17 | 18 | ```python 19 | import asyncio 20 | from easyschedule import EasyScheduler 21 | 22 | scheduler = EasyScheduler() 23 | 24 | default_args = {'args': [1, 2, 3]} 25 | weekday_every_minute = '* * * * MON-FRI' 26 | 27 | @scheduler(schedule=weekday_every_minute, default_args=default_args) 28 | def weekday_stuff(a, b, c): 29 | print(f"a {a} b: {b} c {c}") 30 | 31 | @scheduler.delayed_start(delay_in_seconds=30) 32 | async def delay_startup(): 33 | print(f"## startup task - started ##") 34 | await asyncio.sleep(10) 35 | print(f"## startup task - ended ##") 36 | 37 | @scheduler.shutdown() 38 | async def shutdown(): 39 | print(f"## shutdown task - started ##") 40 | await asyncio.sleep(10) 41 | print(f"## shutdown task - ended ##") 42 | 43 | @scheduler.once(date_string='2022-03-12 16:18:03') 44 | async def next_year(): 45 | print(f"That was a long year") 46 | 47 | async def main(): 48 | # start scheduler 49 | sched = asyncio.create_task(scheduler.start()) 50 | await asyncio.sleep(10) 51 | 52 | # dynamicly schedule task 53 | wk_end_args = {'kwargs': {'count': 5}} 54 | weekend = '30 17-23,0-5 * * SAT,SUN' 55 | 56 | def weekend_stuff(count: int): 57 | for _ in range(count): 58 | weekday_stuff(3,4,5) 59 | weekday_stuff(5,6,7) 60 | 61 | scheduler.schedule( 62 | weekend_stuff, 63 | schedule=weekend, 64 | default_args=wk_end_args 65 | ) 66 | await sched 67 | 68 | asyncio.run(main()) 69 | ``` 70 | ```bash 71 | 03-13 09:09:25 EasyScheduler WARNING weekday_stuff next_run_time: 2021-03-15 00:01:00.143645 72 | 03-13 09:09:25 EasyScheduler WARNING single task delay_startup scheduled to run at 2021-03-13 09:09:55.143337 in 30.0 s 73 | 03-13 09:09:25 EasyScheduler WARNING single task next_year scheduled to run at 2022-03-12 16:18:03 in 31475317.856636 s 74 | 03-13 09:09:35 EasyScheduler WARNING weekend_stuff next_run_time: 2021-03-13 17:31:00.152428 75 | 03-13 09:09:48 EasyScheduler WARNING shutdown task shutdown triggered at 2021-03-13 09:09:48.937516 76 | ## shutdown task - started ## 77 | ## shutdown task - ended ## 78 | Traceback (most recent call last): 79 | File "test.py", line 50, in 80 | asyncio.run(main()) 81 | KeyboardInterrupt 82 | ``` 83 | ## Cron syntax Compatability 84 | 85 | EasySchedule is capable of parsing most cron schedule syntax 86 | 87 | # 88 | ## Monthly 89 | First of month at 11:00 PM 90 | ```bash 91 | 0 23 1 * * 92 | ``` 93 | # 94 | 95 | ## Daily 96 | Every 2 Hours 97 | ```bash 98 | 0 */2 * * 99 | ``` 100 | # 101 | 102 | ## Weekends Only 103 | Every Hour Between 5:30 PM - 5:30 AM ## 104 | ```bash 105 | 30 17-23,0-5 * * SAT,SUN 106 | ``` 107 | ## Cron Generator 108 | An easy & interactive way to build a cron schedule is available via [crontab.guru](https://crontab.guru/) 109 | 110 | ### Note: unsupported syntax (currently) 111 | ```bash 112 | @(non-standard) 113 | @hourly 114 | @daily 115 | @anually 116 | ``` 117 | 118 | # 119 | ## Scheduluing Single Tasks 120 | EasySchedule is complete with single task scheduling 121 | 122 | ### Usage with 'once' decorator 123 | ```python 124 | from datetime import datetime, timedelta 125 | 126 | next_year = datetime.now() + timedelta(days=365) 127 | 128 | @scheduler.once(date=next_year) 129 | async def future_task(): 130 | ## future work 131 | pass 132 | 133 | # current month: 2021-03-13 00:00:00 134 | @scheduler.once(date_string='2021-04-13 00:00:00') 135 | async def run_at_date(): 136 | ## future work 137 | pass 138 | 139 | # current month: 2021-03-13 00:00:00 140 | @scheduler.once(delta=timedelta(days=3)) 141 | async def run_after_delta(): 142 | ## future work 143 | pass 144 | 145 | now_args={'kwargs': {'work': "Lots of work"}} 146 | 147 | @scheduler.once(now=True, default_args=now_args) 148 | async def run_now(work): 149 | ## future work 150 | print(f"starting {work}") 151 | pass 152 | ``` 153 | # 154 | ## Schedule a task at or near application startup 155 | ```python 156 | notify = { 157 | 'kwargs': { 'emails': ['admin@company.org'] } 158 | } 159 | 160 | @scheduler.delayed_start(delay_in_seconds=30, default_args=notify) 161 | async def notify_online(emails: str): 162 | message = f"server is operational" 163 | await send_emails(message, emails) 164 | #something else 165 | 166 | async def get_data(): 167 | return await requests.get('http://data-source') 168 | 169 | @scheduler.startup() 170 | async def update_database(): 171 | data = await get_data() 172 | await db.update(data) 173 | #something else 174 | ``` 175 | # 176 | ## Schedule a task to run at application shutdown 177 | ```python 178 | notify = { 179 | 'kwargs': { 'emails': ['admin@company.org'] } 180 | } 181 | 182 | @scheduler.shutdown(default_args=notify) 183 | async def notify_shutdown(emails: str): 184 | message = f"server is shutting down" 185 | await send_emails(message, emails) 186 | #something else? 187 | ``` -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easyschedule/4023332247ed3645b623b28388d38e13419bf7c4/docs/images/logo.png -------------------------------------------------------------------------------- /docs/images/logo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easyschedule/4023332247ed3645b623b28388d38e13419bf7c4/docs/images/logo3.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ![](./images/logo3.png) 2 | 3 |

An asynchronus scheduler for python tasks, which uses Cron schedules for precise scheduling recurring tasks

4 | 5 | # 6 | 7 | ### Get Started 8 | 9 | #### Installation 10 | ```bash 11 | pip install easyschedule 12 | ``` 13 | 14 | ### Usage 15 | 16 | ```python 17 | import asyncio 18 | from easyschedule import EasyScheduler 19 | 20 | scheduler = EasyScheduler() 21 | 22 | default_args = {'args': [1, 2, 3]} 23 | weekday_every_minute = '* * * * MON-FRI' 24 | 25 | @scheduler(schedule=weekday_every_minute, default_args=default_args) 26 | def weekday_stuff(a, b, c): 27 | print(f"a {a} b: {b} c {c}") 28 | 29 | @scheduler.delayed_start(delay_in_seconds=30) 30 | async def delay_startup(): 31 | print(f"## startup task - started ##") 32 | await asyncio.sleep(10) 33 | print(f"## startup task - ended ##") 34 | 35 | @scheduler.shutdown() 36 | async def shutdown(): 37 | print(f"## shutdown task - started ##") 38 | await asyncio.sleep(10) 39 | print(f"## shutdown task - ended ##") 40 | 41 | @scheduler.once(date_string='2022-03-12 16:18:03') 42 | async def next_year(): 43 | print(f"That was a long year") 44 | 45 | async def main(): 46 | # start scheduler 47 | sched = asyncio.create_task(scheduler.start()) 48 | await asyncio.sleep(10) 49 | 50 | # dynamicly schedule task 51 | wk_end_args = {'kwargs': {'count': 5}} 52 | weekend = '30 17-23,0-5 * * SAT,SUN' 53 | 54 | def weekend_stuff(count: int): 55 | for _ in range(count): 56 | weekday_stuff(3,4,5) 57 | weekday_stuff(5,6,7) 58 | 59 | scheduler.schedule( 60 | weekend_stuff, 61 | schedule=weekend, 62 | default_args=wk_end_args 63 | ) 64 | await sched 65 | 66 | asyncio.run(main()) 67 | ``` 68 | ```bash 69 | 03-13 09:09:25 EasyScheduler WARNING weekday_stuff next_run_time: 2021-03-15 00:01:00.143645 70 | 03-13 09:09:25 EasyScheduler WARNING single task delay_startup scheduled to run at 2021-03-13 09:09:55.143337 in 30.0 s 71 | 03-13 09:09:25 EasyScheduler WARNING single task next_year scheduled to run at 2022-03-12 16:18:03 in 31475317.856636 s 72 | 03-13 09:09:35 EasyScheduler WARNING weekend_stuff next_run_time: 2021-03-13 17:31:00.152428 73 | 03-13 09:09:48 EasyScheduler WARNING shutdown task shutdown triggered at 2021-03-13 09:09:48.937516 74 | ## shutdown task - started ## 75 | ## shutdown task - ended ## 76 | Traceback (most recent call last): 77 | File "test.py", line 50, in 78 | asyncio.run(main()) 79 | KeyboardInterrupt 80 | ``` 81 | ### Cron syntax Compatability 82 | 83 | EasySchedule is capable of parsing most cron schedule syntax 84 | 85 | ### Cron Schedules 86 | 87 | #### Monthly 88 | !!! INFO "Monthly - First of month at 11:00 PM" 89 | 0 23 1 * * 90 | 91 | #### Daily 92 | !!! INFO "Daily - Every 2 Hours" 93 | 0 */2 * * 94 | 95 | #### Weekends Only 96 | !!! INFO "Every Hour Between 5:30 PM - 5:30 AM ##" 97 | 30 17-23,0-5 * * SAT,SUN 98 | 99 | ### Cron Generator 100 | An easy & interactive way to build a cron schedule is available via [crontab.guru](https://crontab.guru/) 101 | 102 | !!! DANGER "Unsupported syntax (currently)" 103 | @(non-standard) 104 | @hourly 105 | @daily 106 | @anually 107 | 108 | 109 | 110 | ### Scheduluing Single Tasks 111 | EasySchedule is complete with single task scheduling 112 | 113 | 114 | #### @scheduler.once() 115 | Usage with 'once' decorator 116 | 117 | ```python 118 | from datetime import datetime, timedelta 119 | 120 | next_year = datetime.now() + timedelta(days=365) 121 | 122 | @scheduler.once(date=next_year) 123 | async def future_task(): 124 | ## future work 125 | pass 126 | 127 | # current month: 2021-03-13 00:00:00 128 | @scheduler.once(date_string='2021-04-13 00:00:00') 129 | async def run_at_date(): 130 | ## future work 131 | pass 132 | 133 | # current month: 2021-03-13 00:00:00 134 | @scheduler.once(delta=timedelta(days=3)) 135 | async def run_after_delta(): 136 | ## future work 137 | pass 138 | 139 | now_args={'kwargs': {'work': "Lots of work"}} 140 | 141 | @scheduler.once(now=True, default_args=now_args) 142 | async def run_now(work): 143 | ## future work 144 | print(f"starting {work}") 145 | pass 146 | ``` 147 | #### @scheduler.delayed_start() 148 | Schedule a task at or near application startup 149 | 150 | ```python 151 | notify = { 152 | 'kwargs': { 'emails': ['admin@company.org'] } 153 | } 154 | 155 | @scheduler.delayed_start(delay_in_seconds=30, default_args=notify) 156 | async def notify_online(emails: str): 157 | message = f"server is operational" 158 | await send_emails(message, emails) 159 | #something else 160 | 161 | async def get_data(): 162 | return await requests.get('http://data-source') 163 | ``` 164 | #### @scheduler.startup() 165 | 166 | ```python 167 | @scheduler.startup() 168 | async def update_database(): 169 | data = await get_data() 170 | await db.update(data) 171 | #something else 172 | ``` 173 | # 174 | ## Schedule a task to run at application shutdown 175 | ```python 176 | notify = { 177 | 'kwargs': { 'emails': ['admin@company.org'] } 178 | } 179 | 180 | @scheduler.shutdown(default_args=notify) 181 | async def notify_shutdown(emails: str): 182 | message = f"server is shutting down" 183 | await send_emails(message, emails) 184 | #something else? 185 | ``` -------------------------------------------------------------------------------- /easyschedule/__init__.py: -------------------------------------------------------------------------------- 1 | from .scheduler import EasyScheduler as EasyScheduler -------------------------------------------------------------------------------- /easyschedule/cron.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | # days of week are purposely -1 of standard CRON 4 | # integers 5 | DAYS_OF_WEEK = { 6 | 'MON': 0, 'TUE': 1, 'WED': 2, 7 | 'THU': 3, 'FRI': 4, 'SAT': 5, 8 | 'SUN': 6, '7': 6, 7: 6, 0: 6, 9 | '0': 6, '1': 0, '2': 1, '3': 2, 10 | '4': 3, '5': 4, '6': 5 11 | } 12 | 13 | MONTHS_OF_YEAR = { 14 | 'JAN': 1, 'FEB': 2, 'MAR': 3, 15 | 'APR': 4, 'MAY': 5, 'JUN': 6, 16 | 'JUL': 7, 'AUG': 8, 'SEP': 9, 17 | 'OCT': 10,'NOV': 11,'DEC': 12 18 | } 19 | def parse_schedule_items(items: str, max_range: int): 20 | if not '*' in items: 21 | if ',' in items: 22 | items_split = items.split(',') 23 | items = [] 24 | for i in items_split: 25 | step = 1 26 | if '/' in i: 27 | i, step = i.split('/') 28 | if '-' in i: 29 | start, end = i.split('-') 30 | items = items + [i for i in range(int(start), int(end)+1, int(step))] 31 | else: 32 | items.append(i) 33 | 34 | items = [int(i) for i in items] 35 | elif '-' in items: 36 | step = 1 37 | if '/' in items: 38 | items, step = items.split('/') 39 | start, end = items.split('-') 40 | items = [i for i in range(int(start), int(end)+1, int(step))] 41 | else: 42 | items = [int(items)] 43 | else: 44 | step = 1 45 | if '/' in items: 46 | items, step = items.split('/') 47 | if '-' in items: 48 | start, end = items.split('-') 49 | items = [i for i in range(int(start), int(end)+1, int(step))] 50 | else: 51 | items = [i for i in range(0, max_range, int(step))] 52 | return items 53 | 54 | def get_schedule_delay(schedule: str) -> tuple: 55 | """ 56 | receives cron schedule and calculates next run time 57 | * * * * * 58 | min hour day month day(week) 59 | 0-59 0-23 1-31 0-12(JAN-DEC) 0-6(SUN-SAT) 60 | """ 61 | now = datetime.datetime.now() 62 | 63 | minutes, hours, days, months, weekdays = schedule.split(' ') 64 | 65 | delta = datetime.datetime.now() 66 | 67 | if not weekdays == '*': 68 | if ',' in weekdays: 69 | weekdays = weekdays.split(',') 70 | if weekdays[0] in DAYS_OF_WEEK: 71 | weekdays = [DAYS_OF_WEEK[d] for d in weekdays] 72 | 73 | elif '-' in weekdays: 74 | weekdays = weekdays.split('-') 75 | if weekdays[0] in DAYS_OF_WEEK: 76 | start = DAYS_OF_WEEK[weekdays[0]] 77 | end = DAYS_OF_WEEK[weekdays[1]] 78 | weekdays = [i for i in range(start, end+1)] 79 | else: 80 | if weekdays in DAYS_OF_WEEK: 81 | weekdays = DAYS_OF_WEEK[weekdays] 82 | weekdays = [weekdays] 83 | else: 84 | weekdays = [int(v) for _,v in DAYS_OF_WEEK.items()] 85 | 86 | # months 87 | 88 | if not '*' in months: 89 | 90 | if ',' in months: 91 | months = months.split(',') 92 | if months[0] in MONTHS_OF_YEAR: 93 | months = [MONTHS_OF_YEAR[m] for m in months] 94 | if '-' in months: 95 | months = months.split('-') 96 | if months[0] in MONTHS_OF_YEAR: 97 | start = MONTHS_OF_YEAR[months[0]] 98 | end = MONTHS_OF_YEAR[months[1]] 99 | assert start < end, f"months start cannot be less than end" 100 | months = [i for i in range(int(start), int(end)+1)] 101 | else: 102 | if months.upper() in MONTHS_OF_YEAR: 103 | months = [MONTHS_OF_YEAR[months.upper()]] 104 | else: 105 | months = [int(months)] 106 | else: 107 | step =1 108 | if '/' in months: 109 | months, step = months.split('/') 110 | if '-' in months: 111 | months = months.split('-') 112 | if months[0] in MONTHS_OF_YEAR: 113 | start = MONTHS_OF_YEAR[months[0]] 114 | end = MONTHS_OF_YEAR[months[1]] 115 | assert start < end, f"months start cannot be less than end" 116 | months = [i for i in range(int(start), int(end)+1)] 117 | else: 118 | if '/' in months: 119 | months, step = months.split('/') 120 | start, end = months.split('-') 121 | months = [i for i in range(int(start), int(end)+1, int(step))] 122 | else: 123 | months = [i for i in range(1,13, int(step))] 124 | 125 | months = [int(i) for i in months] 126 | 127 | # days 128 | days = parse_schedule_items(days, 32) 129 | # Hours 130 | hours = parse_schedule_items(hours, 24) 131 | # minutes 132 | minutes = parse_schedule_items(minutes, 60) 133 | 134 | current = datetime.datetime.now() 135 | 136 | while True: 137 | if current.month not in months: 138 | current = current + datetime.timedelta(hours=23-current.hour, minutes=60-current.minute) 139 | continue 140 | if not current.weekday() in weekdays: 141 | current = current + datetime.timedelta(hours=23-current.hour, minutes=60-current.minute) 142 | continue 143 | 144 | if not current.day in days: 145 | current = current + datetime.timedelta(hours=23-current.hour, minutes=60-current.minute) 146 | continue 147 | if current.hour not in hours: 148 | current = current + datetime.timedelta(minutes=59-current.minute, seconds=60-current.second) 149 | continue 150 | 151 | if current.minute not in minutes: 152 | current = current + datetime.timedelta(seconds=60-current.second) 153 | continue 154 | else: 155 | current = current #+ datetime.timedelta(seconds=60-current.second) 156 | break 157 | delta = current - datetime.datetime.now() 158 | delta_seconds = delta.total_seconds() 159 | 160 | if delta_seconds < 60: 161 | delta_seconds = 60 - current.second 162 | current = current + datetime.timedelta(seconds=delta_seconds) 163 | 164 | return current, delta_seconds -------------------------------------------------------------------------------- /easyschedule/scheduler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from datetime import datetime, timedelta 4 | from typing import Callable, Optional 5 | from easyschedule.cron import get_schedule_delay 6 | 7 | class EasyScheduler: 8 | def __init__( 9 | self, 10 | logger: Optional[logging.Logger] = None, 11 | debug: Optional[bool] = False 12 | ): 13 | self.scheduled_tasks = {} 14 | self.single_tasks = [] 15 | self.loop = None 16 | ## logging 17 | level = 'DEBUG' if debug else None 18 | self.setup_logger(logger=logger, level=level) 19 | 20 | def setup_logger( 21 | self, 22 | logger: logging.Logger = None, 23 | level: str = None 24 | ) -> None: 25 | if logger == None: 26 | level = logging.DEBUG if level == 'DEBUG' else logging.WARNING 27 | logging.basicConfig( 28 | level=level, 29 | format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s', 30 | datefmt='%m-%d %H:%M:%S' 31 | ) 32 | self.log = logging.getLogger(f'EasyScheduler') 33 | self.log.propogate = False 34 | else: 35 | self.log = logger 36 | def __call__( 37 | self, 38 | schedule: str, 39 | default_args: dict = {'args': [], 'kwargs': {}} 40 | ) -> Callable: 41 | if not 'args' in default_args: 42 | default_args['args'] = [] 43 | if not 'kwargs' in default_args: 44 | default_args['kwargs'] = {} 45 | def scheduler(func): 46 | async def scheduled(*args, **kwargs): 47 | result = func(*args, **kwargs) 48 | if asyncio.iscoroutine(result): 49 | result = await result 50 | return result 51 | scheduled.__name__ = func.__name__ 52 | self.scheduled_tasks[scheduled.__name__] = { 53 | 'func': scheduled, 54 | 'schedule': schedule, 55 | 'default_args': default_args 56 | } 57 | if self.loop: 58 | self.schedule_task(scheduled.__name__) 59 | return func 60 | return scheduler 61 | def schedule( 62 | self, 63 | task: Callable, 64 | schedule: str, 65 | default_args: dict = {} 66 | ): 67 | """ 68 | schedule a recurring task 69 | """ 70 | self(schedule=schedule, default_args=default_args)(task) 71 | def run_once( 72 | self, 73 | func: Callable, 74 | date: Optional[datetime] = None, 75 | date_string: Optional[str] = None, 76 | delta: Optional[timedelta] = None, 77 | now: Optional[bool] = False, 78 | on_event: Optional[str] = None, 79 | default_args: Optional[dict] = None, 80 | ): 81 | time_now = datetime.now() 82 | delay = None 83 | if date and date >= time_now: 84 | delay = date - time_now 85 | if date_string: 86 | date = datetime.fromisoformat(date_string) 87 | if date >= time_now: 88 | delay = date - time_now 89 | if delta: 90 | if not isinstance(delta, timedelta): 91 | raise Exception(f"delta is type {type(delta)}, expected {timedelta}") 92 | delay = delta.total_seconds() 93 | if now: 94 | delay = datetime.now() - time_now 95 | if isinstance(delay, timedelta): 96 | delay = delay.total_seconds() 97 | 98 | if not default_args: 99 | default_args = {} 100 | if not 'args' in default_args: 101 | default_args['args'] = [] 102 | if not 'kwargs' in default_args: 103 | default_args['kwargs'] = {} 104 | 105 | if on_event and on_event == 'shutdown': 106 | async def shutdown_task(): 107 | try: 108 | while True: 109 | await asyncio.sleep(60) 110 | except asyncio.CancelledError: 111 | time_now = datetime.now() 112 | self.log.warning(f"shutdown task {func.__name__} triggered at {time_now}") 113 | try: 114 | await func(*default_args['args'], **default_args['kwargs']) 115 | except Exception as e: 116 | self.log.exception(f"error running shutdown task - {task}") 117 | return 118 | 119 | if self.loop: 120 | asyncio.create_task(shutdown_task()) 121 | else: 122 | self.single_tasks.append(shutdown_task) 123 | return 124 | 125 | async def single_task(): 126 | try: 127 | run_time = time_now + timedelta(seconds=delay) 128 | self.log.warning(f"single task {func.__name__} scheduled to run at {run_time} in {delay} s") 129 | await asyncio.sleep(delay) 130 | try: 131 | await func(*default_args['args'], **default_args['kwargs']) 132 | except Exception as e: 133 | if isinstance(e, asyncio.CancelledError): 134 | raise e 135 | self.log.exception(f"error running single task - {func.__name__}") 136 | except asyncio.CancelledError: 137 | return 138 | 139 | if self.loop: 140 | asyncio.create_task(single_task()) 141 | else: 142 | self.single_tasks.append(single_task) 143 | return 144 | def once( 145 | self, 146 | date: Optional[datetime] = None, 147 | date_string: Optional[str] = None, 148 | delta: Optional[timedelta] = None, 149 | now: Optional[bool] = False, 150 | default_args: Optional[dict] = None 151 | ): 152 | """ 153 | Decoractor 154 | runs a single task at at input date, date_string, delta, now 155 | """ 156 | def once_decorator(func): 157 | self.run_once( 158 | func, 159 | date=date, 160 | date_string=date_string, 161 | delta=delta, 162 | now=now, 163 | default_args=default_args 164 | ) 165 | return func 166 | return once_decorator 167 | def startup( 168 | self, 169 | default_args: Optional[dict] = None 170 | ) -> Callable: 171 | """ 172 | decorator 173 | runs a single task right after scheduler.start() 174 | Optional: 175 | default_args = {'args': [], 'kwargs': {}} 176 | """ 177 | if self.loop and self.loop.is_running(): 178 | raise Exception(f"scheduler has already started - cannot add startup tasks") 179 | def startup_decor(func): 180 | 181 | self.run_once( 182 | func, 183 | now=True, 184 | default_args=default_args 185 | ) 186 | return func 187 | return startup_decor 188 | def delayed_start( 189 | self, 190 | delay_in_seconds: int = 60, 191 | default_args: Optional[dict] = None 192 | ): 193 | """ 194 | decorator 195 | runs a single task after scheduler.start() with a delay 196 | 197 | delay_in_seconds: int = 60 #Default 198 | Optional: 199 | default_args = {'args': [], 'kwargs': {}} 200 | """ 201 | def delayed_start_decor(func): 202 | self.run_once( 203 | func, 204 | delta=timedelta(seconds=delay_in_seconds), 205 | default_args=default_args 206 | ) 207 | return func 208 | return delayed_start_decor 209 | def shutdown( 210 | self, 211 | default_args: Optional[dict] = None 212 | ): 213 | """ 214 | decorator 215 | runs a single task after shutdown is detected 216 | 217 | Optional: 218 | default_args = {'args': [], 'kwargs': {}} 219 | """ 220 | def shutdown_decor(func): 221 | self.run_once( 222 | func, 223 | default_args=default_args, 224 | on_event='shutdown' 225 | ) 226 | return func 227 | return shutdown_decor 228 | 229 | 230 | def schedule_task(self, task: str) -> None: 231 | async def scheduled_task(): 232 | try: 233 | while True: 234 | func = self.scheduled_tasks[task]['func'] 235 | schedule = self.scheduled_tasks[task]['schedule'] 236 | default_args = self.scheduled_tasks[task]['default_args'] 237 | next_run_time, delay = get_schedule_delay(schedule) 238 | self.log.warning(f"{task} next_run_time: {next_run_time} - default_args: {default_args}") 239 | await asyncio.sleep(delay) 240 | try: 241 | await func(*default_args['args'], **default_args['kwargs']) 242 | except Exception as e: 243 | self.log.exception(f"error running scheduled task - {task}") 244 | except asyncio.CancelledError: 245 | return 246 | self.log.debug(f"schedule_task called for {self.scheduled_tasks[task]}") 247 | self.scheduled_tasks[task]['task'] = self.loop.create_task( 248 | scheduled_task() 249 | ) 250 | async def start(self): 251 | if not self.loop: 252 | self.loop = asyncio.get_running_loop() 253 | for task in self.scheduled_tasks: 254 | self.schedule_task(task) 255 | for task in self.single_tasks: 256 | asyncio.create_task(task()) 257 | try: 258 | while True: 259 | await asyncio.sleep(60) 260 | except asyncio.CancelledError: 261 | return -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easyschedule/4023332247ed3645b623b28388d38e13419bf7c4/images/logo.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: EasySchedule 2 | theme: readthedocs 3 | markdown_extensions: 4 | - admonition -------------------------------------------------------------------------------- /nextbuild.py: -------------------------------------------------------------------------------- 1 | """ 2 | Purpose: 3 | Increments current Pypi version by .001 4 | 5 | Usage: 6 | pip3 download easyschedule && ls easy_auth*.whl | sed 's/-/" "/g' | awk '{print "(" $2 ")"}' | python3 python/easyschedule/easyschedule/nextbuild.py 7 | """ 8 | if __name__=='__main__': 9 | import sys 10 | version = sys.stdin.readline().rstrip() 11 | if '(' in version and ')' in version: 12 | right_i = version.index('(') 13 | left_i = version.index(')') 14 | version = version[right_i+2:left_i-1] 15 | print(f"{float(version)+0.001:.3f}") -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | BASE_REQUIREMENTS = [] 4 | SERVER_REQUIREMENTS = [] 5 | CLIENT_REQUIREMENTS = [] 6 | 7 | with open("README.md", "r") as fh: 8 | long_description = fh.read() 9 | setuptools.setup( 10 | name='easyschedule', 11 | version='NEXTVERSION', 12 | packages=setuptools.find_packages(include=['easyschedule'], exclude=['build']), 13 | author="Joshua Jamison", 14 | author_email="joshjamison1@gmail.com", 15 | description="Easily schedule single or recurring sync/async tasks", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/codemation/easyschedule", 19 | classifiers=[ 20 | "Programming Language :: Python :: 3", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | ], 24 | python_requires='>=3.7, <4', 25 | install_requires=BASE_REQUIREMENTS 26 | ) -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from easyschedule import EasyScheduler 3 | 4 | scheduler = EasyScheduler() 5 | 6 | default_args = {'args': [1, 2, 3]} 7 | weekday_every_minute = '* * * * MON-FRI' 8 | 9 | @scheduler(schedule=weekday_every_minute, default_args={'kwargs': {'url': 'test'}}) 10 | def minute_stuff(url): 11 | print(f"minute_stuff: url - {url}") 12 | 13 | @scheduler(schedule=weekday_every_minute, default_args=default_args) 14 | def weekday_stuff(a, b, c): 15 | print(f"a {a} b: {b} c {c}") 16 | 17 | @scheduler.delayed_start(delay_in_seconds=30) 18 | async def delay_startup(): 19 | print(f"## startup task - started ##") 20 | await asyncio.sleep(10) 21 | print(f"## startup task - ended ##") 22 | 23 | @scheduler.shutdown() 24 | async def shutdown(): 25 | print(f"## shutdown task - started ##") 26 | await asyncio.sleep(10) 27 | print(f"## shutdown task - ended ##") 28 | 29 | @scheduler.once(date_string='2022-03-12 16:18:03') 30 | async def next_year(): 31 | print(f"That was a long year") 32 | 33 | async def main(): 34 | # start scheduler 35 | sched = asyncio.create_task(scheduler.start()) 36 | await asyncio.sleep(10) 37 | 38 | # dynamicly schedule task 39 | wk_end_args = {'kwargs': {'count': 5}} 40 | weekend = '30 17-23,0-5 * * SAT,SUN' 41 | 42 | def weekend_stuff(count: int): 43 | for _ in range(count): 44 | print_stuff(3,4,5) 45 | print_stuff(5,6,7) 46 | 47 | scheduler.schedule( 48 | weekend_stuff, 49 | schedule=weekend, 50 | default_args=wk_end_args 51 | ) 52 | await sched 53 | 54 | asyncio.run(main()) --------------------------------------------------------------------------------