├── CONTRIBUTORS.txt ├── LICENSE ├── MANIFEST.in ├── README.md ├── crontabs ├── __init__.py ├── crontabs.py ├── processes.py ├── tests │ ├── __init__.py │ └── test_all.py └── version.py ├── docs ├── Makefile ├── conf.py ├── index.rst ├── ref │ └── crontabs.rst └── toc.rst ├── publish.py ├── setup.cfg └── setup.py /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Rob deCarvalho (unlisted@unlisted.net) 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Rob deCarvalho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CONTRIBUTORS.txt 3 | include LICENSE 4 | prune */tests 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crontabs 2 | 3 | --- 4 | 5 | 6 | **NOTE:** 7 | I've recently discovered the [Rocketry](https://github.com/Miksus/rocketry/) and [Huey](https://github.com/coleifer/huey) projects, which you should probably use instead of crontabs. They are just better than crontabs. 8 | 9 | 10 | --- 11 | 12 | 13 | Think of crontabs as a quick-and-dirty solution you can throw into one-off python scripts to execute tasks on a cron-like schedule. 14 | 15 | Crontabs is a small pure-python library that was inspired by the excellent [schedule](https://github.com/dbader/schedule) library for python. 16 | 17 | In addition to having a slightly different API, crontabs differs from the schedule module in the following 18 | ways. 19 | 20 | * You do not need to provide your own event loop. 21 | * Job timing is guaranteed not to drift over time. For example, if you specify to run a job every five minutes, 22 | you can rest assured that it will always run at 5, 10, 15, etc. passed the hour with no drift. 23 | * The python functions are all run in child processes. A memory-friendly flag is available to run each 24 | iteration of your task in its own process thereby mitigating memory problems due to Python's 25 | [high watermark issue](https://hbfs.wordpress.com/2013/01/08/python-memory-management-part-ii/) 26 | 27 | # Why Crontabs 28 | Python has no shortage of [cron-like job scheduling libraries](https://pypi.python.org/pypi?%3Aaction=search&term=cron), so why create yet another. The honest answer is that I couldn't find one that met a simple list of criteria. 29 | * **Simple installation with no configuration.** An extremely robust and scalable solution to this problem already exists. [Celery](http://www.celeryproject.org/). But for quick and dirty work, I didn't want the hastle of setting up and configuring a broker, which celery requires to do its magic. For simple jobs, I just wanted to pip install and go. 30 | * **Human readable interface.** I loved the interface provided by the [schedule](https://github.com/dbader/schedule) library and wanted something similarly intuitive to use. 31 | * **Memory safe for long running jobs.** Celery workers can suffer from severe memory bloat due to the way Python manages memory. As of 2017, the recommended solution for this was to periodically restart the workers. Crontabs runs each job in a subprocess. It can optionally also run each iteration of a task in it's own process thereby mitigating the memory bloat issue. 32 | * **Simple solution for cron-style workflow and nothing more.** I was only interested in supporting cron-like functionality, and wasn't interested in all the other capabilities and guarantees offered by a real task-queue solution like celery. 33 | * **Suggestions for improvement welcome.** If you encounter a bug or have an improvement that remains within the scope listed above, please feel free to open an issue (or even better... a PR). 34 | 35 | # Installation 36 | ```bash 37 | pip install crontabs 38 | ``` 39 | # Usage 40 | 41 | ### Schedule a single job 42 | ```python 43 | from crontabs import Cron, Tab 44 | from datetime import datetime 45 | 46 | 47 | def my_job(*args, **kwargs): 48 | print('args={} kwargs={} running at {}'.format(args, kwargs, datetime.now())) 49 | 50 | 51 | # Will run with a 5 second interval synced to the top of the minute 52 | Cron().schedule( 53 | Tab(name='run_my_job').every(seconds=5).run(my_job, 'my_arg', my_kwarg='hello') 54 | ).go() 55 | 56 | ``` 57 | 58 | ### Schedule multiple jobs 59 | ```python 60 | from crontabs import Cron, Tab 61 | from datetime import datetime 62 | 63 | 64 | def my_job(*args, **kwargs): 65 | print('args={} kwargs={} running at {}'.format(args, kwargs, datetime.now())) 66 | 67 | 68 | # All logging messages are sent to sdtout 69 | Cron().schedule( 70 | # Turn off logging for job that runs every five seconds 71 | Tab(name='my_fast_job', verbose=False).every(seconds=5).run(my_job, 'fast', seconds=5), 72 | 73 | # Go ahead and let this job emit logging messages 74 | Tab(name='my_slow_job').every(seconds=20).run(my_job, 'slow', seconds=20), 75 | ).go() 76 | 77 | ``` 78 | 79 | ### Schedule future job to run repeatedly for a fixed amount of time 80 | ```python 81 | from crontabs import Cron, Tab 82 | from datetime import datetime 83 | 84 | 85 | def my_job(*args, **kwargs): 86 | print('args={} kwargs={} running at {}'.format(args, kwargs, datetime.now())) 87 | 88 | 89 | Cron().schedule( 90 | Tab( 91 | name='future_job' 92 | ).every( 93 | seconds=5 94 | ).starting( 95 | '12/27/2017 16:45' # This argument can either be parsable text or datetime object. 96 | ).run( 97 | my_job, 'fast', seconds=5 98 | ) 99 | # max_seconds starts from the moment go is called. Pad for future run times accordingly. 100 | ).go(max_seconds=60) 101 | ``` 102 | 103 | # Cron API 104 | The `Cron` class has a very small api 105 | 106 | | method | Description | 107 | | --- | --- | 108 | | `.schedule()` |[**Required**] Specify the different jobs you want using `Tab` instances| 109 | | `.go()` | [**Required**] Start the crontab manager to run all specified tasks| 110 | | `.get_logger()` | A class method you can use to get an instance of the crontab logger| 111 | 112 | # Tab API with examples 113 | The api for the `Tab` class is designed to be composable and readable in plain English. It supports 114 | the following "verbs" by invoking methods. 115 | 116 | | method | Description | 117 | | --- | --- | 118 | | `.run()` |[**Required**] Specify the function to run. | 119 | | `.every()` |[**Required**] Specify the interval between function calls.| 120 | | `.starting()` | [**Optional**] Specify an explicit time for the function calls to begin.| 121 | | `.lasting()` | [**Optional**] Specify how long the task will continue being iterated.| 122 | | `.until()` | [**Optional**] Specify an explicit time past which the iteration will stop 123 | | `.during()` | [**Optional**] Specify time conditions under which the function will run 124 | | `.excluding()` | [**Optional**] Specify time conditions under which the function will be inhibited 125 | 126 | ## Run a job indefinitely 127 | ```python 128 | from crontabs import Cron, Tab 129 | from datetime import datetime 130 | 131 | 132 | def my_job(name): 133 | print('Running function with name={}'.format(name)) 134 | 135 | 136 | Cron().schedule( 137 | Tab(name='forever').every(seconds=5).run(my_job, 'my_func'), 138 | ).go() 139 | 140 | ``` 141 | 142 | ## Run one job indefinitely, another for thirty seconds, and another until 1/1/2030 143 | ```python 144 | from crontabs import Cron, Tab 145 | from datetime import datetime 146 | 147 | 148 | def my_job(name): 149 | print('Running function with name={}'.format(name)) 150 | 151 | 152 | Cron().schedule( 153 | Tab(name='forever').run(my_job, 'forever_job').every(seconds=5), 154 | Tab(name='for_thirty').run(my_job, 'mortal_job').every(seconds=5).lasting(seconds=30), 155 | Tab(name='real_long').run(my_job, 'long_job').every(seconds=5).until('1/1/2030'), 156 | ).go() 157 | 158 | ``` 159 | 160 | ## Run job every half hour from 9AM to 5PM excluding weekends 161 | ```python 162 | from crontabs import Cron, Tab 163 | from datetime import datetime 164 | 165 | def my_job(name): 166 | # Grab an instance of the crontab logger and write to it. 167 | logger = Cron.get_logger() 168 | logger.info('Running function with name={}'.format(name)) 169 | 170 | 171 | def business_hours(timestamp): 172 | return 9 <= timestamp.hour < 17 173 | 174 | def weekends(timestamp): 175 | return timestamp.weekday() > 4 176 | 177 | 178 | # Run a job every 30 minutes during weekdays. Stop crontabs after it has been running for a year. 179 | # This will indiscriminately kill every Tab it owns at that time. 180 | Cron().schedule( 181 | Tab( 182 | name='my_job' 183 | ).run( 184 | my_job, 'my_job' 185 | ).every( 186 | minutes=30 187 | ).during( 188 | business_hours 189 | ).excluding( 190 | weekends 191 | ) 192 | ).go(max_seconds=3600 * 24 * 365) 193 | ``` 194 | 195 | 196 | # Run test suite with 197 | ```bash 198 | git clone git@github.com:robdmc/crontabs.git 199 | cd crontabs 200 | pip install -e .[dev] 201 | py.test -s -n 8 # Might need to change the -n amount to pass 202 | ``` 203 | 204 | ___ 205 | Projects by [robdmc](https://www.linkedin.com/in/robdecarvalho). 206 | * [Pandashells](https://github.com/robdmc/pandashells) Pandas at the bash command line 207 | * [Consecution](https://github.com/robdmc/consecution) Pipeline abstraction for Python 208 | * [Behold](https://github.com/robdmc/behold) Helping debug large Python projects 209 | * [Crontabs](https://github.com/robdmc/crontabs) Simple scheduling library for Python scripts 210 | * [Switchenv](https://github.com/robdmc/switchenv) Manager for bash environments 211 | * [Gistfinder](https://github.com/robdmc/gistfinder) Fuzzy-search your gists 212 | -------------------------------------------------------------------------------- /crontabs/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .version import __version__ 3 | 4 | from .crontabs import Cron, Tab 5 | -------------------------------------------------------------------------------- /crontabs/crontabs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for manageing crontabs interface 3 | """ 4 | import datetime 5 | import functools 6 | import time 7 | import traceback 8 | import warnings 9 | 10 | import daiquiri 11 | from dateutil.parser import parse 12 | from dateutil.relativedelta import relativedelta 13 | from fleming import fleming 14 | from .processes import ProcessMonitor 15 | 16 | import logging 17 | daiquiri.setup(level=logging.INFO) 18 | 19 | 20 | class Cron: 21 | @classmethod 22 | def get_logger(self, name='crontab_log'): 23 | logger = daiquiri.getLogger(name) 24 | return logger 25 | 26 | def __init__(self): 27 | """ 28 | A Cron object runs many "tabs" of asynchronous tasks. 29 | """ 30 | self.monitor = ProcessMonitor() 31 | self._tab_list = [] 32 | 33 | def schedule(self, *tabs): 34 | self._tab_list = list(tabs) 35 | return self 36 | 37 | def go(self, max_seconds=None): 38 | for tab in self._tab_list: 39 | target = tab._get_target() 40 | self.monitor.add_subprocess(tab._name, target, tab._robust, tab._until) 41 | try: 42 | self.monitor.loop(max_seconds=max_seconds) 43 | except KeyboardInterrupt: # pragma: no cover 44 | pass 45 | 46 | 47 | class Tab: 48 | _SILENCE_LOGGER = False 49 | 50 | def __init__(self, name, robust=True, verbose=True, memory_friendly=False): 51 | """ 52 | Schedules a Tab entry in the cron runner 53 | :param name: Every tab must have a string name 54 | :param robust: A robust tab will be restarted if an error occures 55 | A non robust tab will not be restarted, but all other 56 | non-errored tabs should continue running 57 | :param verbose: Set the verbosity of log messages. 58 | :memory friendly: If set to true, each iteration will be run in separate process 59 | """ 60 | if not isinstance(name, str): 61 | raise ValueError('Name argument must be a string') 62 | 63 | self._name = name 64 | self._robust = robust 65 | self._verbose = verbose 66 | self._starting = None 67 | self._every_kwargs = None 68 | self._func = None 69 | self._func_args = None 70 | self._func_kwargs = None 71 | self._exclude_func = self._default_exclude_func 72 | self._during_func = self._default_during_func 73 | self._memory_friendly = memory_friendly 74 | self._until = None 75 | self._lasting_delta = None 76 | 77 | def _default_exclude_func(self, t): 78 | return False 79 | 80 | def _default_during_func(self, t): 81 | return True 82 | 83 | def _log(self, msg): 84 | if self._verbose and not self._SILENCE_LOGGER: # pragma: no cover 85 | logger = daiquiri.getLogger(self._name) 86 | logger.info(msg) 87 | 88 | def _process_date(self, datetime_or_str): 89 | if isinstance(datetime_or_str, str): 90 | return parse(datetime_or_str) 91 | elif isinstance(datetime_or_str, datetime.datetime): 92 | return datetime_or_str 93 | else: 94 | raise ValueError('.starting() and until() method can only take strings or datetime objects') 95 | 96 | def starting(self, datetime_or_str): 97 | """ 98 | Set the starting time for the cron job. If not specified, the starting time will always 99 | be the beginning of the interval that is current when the cron is started. 100 | 101 | :param datetime_or_str: a datetime object or a string that dateutil.parser can understand 102 | :return: self 103 | """ 104 | self._starting = self._process_date(datetime_or_str) 105 | return self 106 | 107 | def starting_at(self, datetime_or_str): 108 | warnings.warn('.starting_at() is depricated. Use .starting() instead') 109 | return self.starting(datetime_or_str) 110 | 111 | def until(self, datetime_or_str): 112 | """ 113 | Run the tab until the specified time is reached. At that point, deactivate the expired 114 | tab so that it no longer runs. 115 | 116 | :param datetime_or_str: a datetime object or a string that dateutil.parser can understand 117 | :return: self 118 | """ 119 | self._until = self._process_date(datetime_or_str) 120 | return self 121 | 122 | def lasting(self, **kwargs): 123 | """ 124 | Run the tab so that it lasts this long. The argument structure is exactly the same 125 | as that of the .every() method 126 | """ 127 | relative_delta_kwargs = {k if k.endswith('s') else k + 's': v for (k, v) in kwargs.items()} 128 | self._lasting_delta = relativedelta(**relative_delta_kwargs) 129 | return self 130 | 131 | def excluding(self, func, name=''): 132 | """ 133 | Pass a function that takes a timestamp for when the function should execute. 134 | It inhibits running when the function returns True. 135 | Optionally, add a name to the exclusion. This name will act as an explanation 136 | in the log for why the exclusion was made. 137 | """ 138 | self._exclude_func = func 139 | self._exclude_name = name 140 | 141 | return self 142 | 143 | def during(self, func, name=''): 144 | """ 145 | Pass a function that takes a timestamp for when the function should execute. 146 | It will only run if the function returns true. 147 | Optionally, add a name. This name will act as an explanation in the log for why 148 | any exclusions were made outside the "during" specification. 149 | """ 150 | self._during_func = func 151 | self._during_name = name 152 | 153 | return self 154 | 155 | def every(self, **kwargs): 156 | """ 157 | Specify the interval at which you want the job run. Takes exactly one keyword argument. 158 | That argument must be one named one of [second, minute, hour, day, week, month, year] or 159 | their plural equivalents. 160 | 161 | :param kwargs: Exactly one keyword argument 162 | :return: self 163 | """ 164 | if len(kwargs) != 1: 165 | raise ValueError('.every() method must be called with exactly one keyword argument') 166 | 167 | self._every_kwargs = self._clean_kwargs(kwargs) 168 | 169 | return self 170 | 171 | def run(self, func, *func_args, **func__kwargs): 172 | """ 173 | Specify the function to run at the scheduled times 174 | 175 | :param func: a callable 176 | :param func_args: the args to the callable 177 | :param func__kwargs: the kwargs to the callable 178 | :return: 179 | """ 180 | self._func = func 181 | self._func_args = func_args 182 | self._func_kwargs = func__kwargs 183 | return self 184 | 185 | def _clean_kwargs(self, kwargs): 186 | allowed_key_map = { 187 | 'seconds': 'second', 188 | 'second': 'second', 189 | 'minutes': 'minute', 190 | 'minute': 'minute', 191 | 'hours': 'hour', 192 | 'hour': 'hour', 193 | 'days': 'day', 194 | 'day': 'day', 195 | 'weeks': 'week', 196 | 'week': 'week', 197 | 'months': 'month', 198 | 'month': 'month', 199 | 'years': 'year', 200 | 'year': 'year', 201 | } 202 | 203 | kwargs = {k if k.endswith('s') else k + 's': v for (k, v) in kwargs.items()} 204 | 205 | out_kwargs = {} 206 | for key in kwargs.keys(): 207 | out_key = allowed_key_map.get(key.lower()) 208 | if out_key is None: 209 | raise ValueError('Allowed time names are {}'.format(sorted(allowed_key_map.keys()))) 210 | out_kwargs[out_key] = kwargs[key] 211 | 212 | return out_kwargs 213 | 214 | def _is_uninhibited(self, time_stamp): 215 | can_run = True 216 | msg = 'inhibited: ' 217 | if self._exclude_func(time_stamp): 218 | if self._exclude_name: 219 | msg += self._exclude_name 220 | can_run = False 221 | 222 | if can_run and not self._during_func(time_stamp): 223 | if self._during_name: 224 | msg += self._during_name 225 | can_run = False 226 | 227 | if not can_run: 228 | self._log(msg) 229 | 230 | return can_run 231 | 232 | def _loop(self, max_iter=None): 233 | if not self._SILENCE_LOGGER: # pragma: no cover don't want to clutter tests 234 | logger = daiquiri.getLogger(self._name) 235 | logger.info('Starting {}'.format(self._name)) 236 | # fleming and dateutil have arguments that just differ by ending in an "s" 237 | fleming_kwargs = self._every_kwargs 238 | relative_delta_kwargs = {} 239 | 240 | # build the relative delta kwargs 241 | for k, v in self._every_kwargs.items(): 242 | relative_delta_kwargs[k + 's'] = v 243 | 244 | # Previous time is the latest interval boundary that has already happened 245 | previous_time = fleming.floor(datetime.datetime.now(), **fleming_kwargs) 246 | 247 | # keep track of iterations 248 | n_iter = 0 249 | # this is the infinite loop that runs the cron. It will only be stopped when the 250 | # process is killed by its monitor. 251 | while True: 252 | n_iter += 1 253 | if max_iter is not None and n_iter > max_iter: 254 | break 255 | # everything is run in a try block so errors can be explicitly handled 256 | try: 257 | # push forward the previous/next times 258 | next_time = previous_time + relativedelta(**relative_delta_kwargs) 259 | previous_time = next_time 260 | 261 | # get the current time 262 | now = datetime.datetime.now() 263 | 264 | # if our job ran longer than an interval, we will need to catch up 265 | if next_time < now: 266 | continue 267 | 268 | # sleep until the computed time to run the function 269 | sleep_seconds = (next_time - now).total_seconds() 270 | time.sleep(sleep_seconds) 271 | 272 | # See what time it is on wakeup 273 | timestamp = datetime.datetime.now() 274 | 275 | # If passed until date, break out of here 276 | if self._until is not None and timestamp > self._until: 277 | break 278 | 279 | # If not inhibited, run the function 280 | if self._is_uninhibited(timestamp): 281 | self._log('Running {}'.format(self._name)) 282 | self._func(*self._func_args, **self._func_kwargs) 283 | 284 | except KeyboardInterrupt: # pragma: no cover 285 | pass 286 | 287 | except: # noqa 288 | # only raise the error if not in robust mode. 289 | if self._robust: 290 | s = 'Error in tab\n' + traceback.format_exc() 291 | logger = daiquiri.getLogger(self._name) 292 | logger.error(s) 293 | else: 294 | raise 295 | self._log('Finishing {}'.format(self._name)) 296 | 297 | def _get_target(self): 298 | """ 299 | returns a callable with no arguments designed 300 | to be the target of a Subprocess 301 | """ 302 | if None in [self._func, self._func_kwargs, self._func_kwargs, self._every_kwargs]: 303 | raise ValueError('You must call the .every() and .run() methods on every tab.') 304 | 305 | if self._memory_friendly: # pragma: no cover TODO: need to find a way to test this 306 | target = functools.partial(self._loop, max_iter=1) 307 | else: # pragma: no cover TODO: need to find a way to test this 308 | target = self._loop 309 | 310 | if self._lasting_delta is not None: 311 | self._until = datetime.datetime.now() + self._lasting_delta 312 | 313 | return target 314 | -------------------------------------------------------------------------------- /crontabs/processes.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | import daiquiri 4 | 5 | try: # pragma: no cover 6 | from Queue import Empty 7 | except: # noqa pragma: no cover 8 | from queue import Empty 9 | 10 | from multiprocessing import Process, Queue 11 | import datetime 12 | import sys 13 | 14 | 15 | class SubProcess: 16 | def __init__( 17 | self, 18 | name, 19 | target, 20 | q_stdout, 21 | q_stderr, 22 | q_error, 23 | robust, 24 | until=None, 25 | args=None, 26 | kwargs=None, 27 | ): 28 | # set up the io queues 29 | self.q_stdout = q_stdout 30 | self.q_stderr = q_stderr 31 | self.q_error = q_error 32 | 33 | self._robust = robust 34 | self._until = until 35 | 36 | # Setup the name of the sub process 37 | self._name = name 38 | 39 | # Save the target of the process 40 | self._target = target 41 | 42 | # Save the args to the process 43 | self._args = args or set() 44 | 45 | # Setup a reference to the process 46 | self._process = None 47 | 48 | # Save the kwargs to the process 49 | self._kwargs = kwargs or {} 50 | 51 | self._has_logged_expiration = False 52 | 53 | @property 54 | def expired(self): 55 | expired = False 56 | if self._until is not None and self._until < datetime.datetime.now(): 57 | expired = True 58 | if not self._has_logged_expiration: 59 | self._has_logged_expiration = True 60 | logger = daiquiri.getLogger(self._name) 61 | logger.info('Process expired and will no longer run') 62 | return expired 63 | 64 | def is_alive(self): 65 | return self._process is not None and self._process.is_alive() 66 | 67 | def start(self): 68 | 69 | self._process = Process( 70 | target=wrapped_target, 71 | args=[ 72 | self._target, self.q_stdout, self.q_stderr, 73 | self.q_error, self._robust, self._name 74 | ] + list(self._args), 75 | kwargs=self._kwargs 76 | ) 77 | self._process.daemon = True 78 | self._process.start() 79 | 80 | 81 | class IOQueue: # pragma: no cover 82 | """ 83 | Okay, so here is something annoying. If you spawn a python subprocess, you cannot 84 | pipe stdout/stderr in the same way you can with the parent process. People who 85 | run this library probably want to be able to redirect output to logs. The best way 86 | I could figure out to handle this was to monkey patch stdout and stderr in the 87 | subprocesses to be an instance of this class. All this does is send write() messages 88 | to a queue that is monitored by the parent process and prints to parent stdtou/stderr 89 | """ 90 | def __init__(self, q): 91 | self._q = q 92 | 93 | def write(self, item): 94 | self._q.put(item) 95 | 96 | def flush(self): 97 | pass 98 | 99 | 100 | def wrapped_target(target, q_stdout, q_stderr, q_error, robust, name, *args, **kwargs): # pragma: no cover 101 | """ 102 | Wraps a target with queues replacing stdout and stderr 103 | """ 104 | import sys 105 | sys.stdout = IOQueue(q_stdout) 106 | sys.stderr = IOQueue(q_stderr) 107 | 108 | try: 109 | target(*args, **kwargs) 110 | 111 | except: # noqa 112 | if not robust: 113 | s = 'Error in tab\n' + traceback.format_exc() 114 | logger = daiquiri.getLogger(name) 115 | logger.error(s) 116 | else: 117 | raise 118 | 119 | if not robust: 120 | q_error.put(name) 121 | raise 122 | 123 | 124 | class ProcessMonitor: 125 | TIMEOUT_SECONDS = .05 126 | 127 | def __init__(self): 128 | 129 | self._subprocesses = [] 130 | self._is_running = False 131 | self.q_stdout = Queue() 132 | self.q_stderr = Queue() 133 | self.q_error = Queue() 134 | 135 | def add_subprocess(self, name, func, robust, until, *args, **kwargs): 136 | sub = SubProcess( 137 | name, 138 | target=func, 139 | q_stdout=self.q_stdout, 140 | q_stderr=self.q_stderr, 141 | q_error=self.q_error, 142 | robust=robust, 143 | until=until, 144 | args=args, 145 | kwargs=kwargs 146 | ) 147 | self._subprocesses.append(sub) 148 | 149 | def process_io_queue(self, q, stream): 150 | try: 151 | out = q.get(timeout=self.TIMEOUT_SECONDS) 152 | out = out.strip() 153 | if out: 154 | stream.write(out + '\n') 155 | stream.flush() 156 | except Empty: 157 | pass 158 | 159 | def process_error_queue(self, error_queue): 160 | try: 161 | error_name = error_queue.get(timeout=self.TIMEOUT_SECONDS) 162 | if error_name: 163 | error_name = error_name.strip() 164 | self._subprocesses = [s for s in self._subprocesses if s._name != error_name] 165 | logger = daiquiri.getLogger(error_name) 166 | logger.info('Will not auto-restart because it\'s not robust') 167 | 168 | except Empty: 169 | pass 170 | 171 | def loop(self, max_seconds=None): 172 | """ 173 | Main loop for the process. This will run continuously until maxiter 174 | """ 175 | loop_started = datetime.datetime.now() 176 | 177 | self._is_running = True 178 | while self._is_running: 179 | self.process_error_queue(self.q_error) 180 | 181 | if max_seconds is not None: 182 | if (datetime.datetime.now() - loop_started).total_seconds() > max_seconds: 183 | logger = daiquiri.getLogger('crontabs') 184 | logger.info('Crontabs reached specified timeout. Exiting.') 185 | break 186 | for subprocess in self._subprocesses: 187 | if not subprocess.is_alive() and not subprocess.expired: 188 | subprocess.start() 189 | 190 | self.process_io_queue(self.q_stdout, sys.stdout) 191 | self.process_io_queue(self.q_stderr, sys.stderr) 192 | -------------------------------------------------------------------------------- /crontabs/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | -------------------------------------------------------------------------------- /crontabs/tests/test_all.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from unittest import TestCase 3 | import datetime 4 | import functools 5 | import sys 6 | import time 7 | 8 | from crontabs import Cron, Tab 9 | from dateutil.parser import parse 10 | from dateutil.relativedelta import relativedelta 11 | import fleming 12 | 13 | Tab._SILENCE_LOGGER = True 14 | 15 | # Run tests with 16 | # py.test -s crontabs/tests/test_example.py::TestSample::test_base_case 17 | # Or for parallel tests 18 | # py.test -s --cov -n 2 19 | 20 | 21 | class ExpectedException(Exception): 22 | pass 23 | 24 | 25 | class PrintCatcher(object): # pragma: no cover This is a testing utility that doesn't need to be covered 26 | def __init__(self, stream='stdout'): 27 | self.text = '' 28 | if stream not in {'stdout', 'stderr'}: # pragma: no cover this is just a testing utitlity 29 | raise ValueError('stream must be either "stdout" or "stderr"') 30 | self.stream = stream 31 | 32 | def write(self, text): 33 | self.text += text 34 | 35 | def flush(self): 36 | pass 37 | 38 | def __enter__(self): 39 | if self.stream == 'stdout': 40 | sys.stdout = self 41 | else: 42 | sys.stderr = self 43 | return self 44 | 45 | def __exit__(self, *args): 46 | if self.stream == 'stdout': 47 | sys.stdout = sys.__stdout__ 48 | else: 49 | sys.stderr = sys.__stderr__ 50 | 51 | 52 | def time_logger(name): # pragma: no cover 53 | print('{} {}'.format(name, datetime.datetime.now())) 54 | 55 | 56 | def time__sleepy_logger(name): # pragma: no cover 57 | time.sleep(3) 58 | print('{} {}'.format(name, datetime.datetime.now())) 59 | 60 | 61 | def error_raisor(name): 62 | raise ExpectedException('This exception is expected in tests. Don\'t worry about it.') 63 | 64 | 65 | class TestCrontabs(TestCase): 66 | 67 | def test_non_robust_error(self): 68 | tab = Tab( 69 | 'one_sec', verbose=False, robust=False 70 | ).every(seconds=1).run( 71 | error_raisor, 'one_sec') 72 | 73 | with self.assertRaises(ExpectedException): 74 | tab._loop(max_iter=1) 75 | 76 | def test_robust_error(self): 77 | tab = Tab( 78 | 'one_sec', verbose=False 79 | ).every(seconds=1).run( 80 | error_raisor, 'one_sec') 81 | tab._loop(max_iter=1) 82 | 83 | def test_tab_loop_sleepy(self): 84 | tab = Tab( 85 | 'one_sec', verbose=False 86 | ).every(seconds=1).run( 87 | time__sleepy_logger, 'one_sec') 88 | with PrintCatcher() as catcher: 89 | tab._loop(max_iter=7) 90 | self.assertEqual(catcher.text.count('one_sec'), 2) 91 | 92 | def test_tab_loop_anchored(self): 93 | now = datetime.datetime.now() + datetime.timedelta(seconds=1) 94 | tab = Tab( 95 | 'one_sec', verbose=False 96 | ).every(seconds=1).starting( 97 | now).run( 98 | time_logger, 'one_sec') 99 | with PrintCatcher() as catcher: 100 | tab._loop(max_iter=3) 101 | self.assertEqual(catcher.text.count('one_sec'), 3) 102 | 103 | def test_tab_loop(self): 104 | tab = Tab( 105 | 'one_sec', verbose=False).every(seconds=1).run( 106 | time_logger, 'one_sec') 107 | with PrintCatcher() as catcher: 108 | tab._loop(max_iter=3) 109 | 110 | self.assertEqual(catcher.text.count('one_sec'), 3) 111 | 112 | def test_incomplete(self): 113 | with self.assertRaises(ValueError): 114 | Cron().schedule(Tab('a').run(time_logger, 'bad')).go() 115 | 116 | def test_bad_starting(self): 117 | with self.assertRaises(ValueError): 118 | Tab('a').starting(2.345) 119 | # Cron().schedule(Tab('a').starting(2.345)) 120 | 121 | def test_bad_every(self): 122 | with self.assertRaises(ValueError): 123 | Tab('a').every(second=1, minute=3) 124 | # Cron().schedule(Tab('a').every(second=1, minute=3)) 125 | 126 | def test_bad_interval(self): 127 | with self.assertRaises(ValueError): 128 | Tab('a').every(bad=11) 129 | # Cron().schedule(Tab('a').every(bad=11)) 130 | 131 | def test_base_case(self): 132 | cron = Cron() 133 | cron.schedule( 134 | Tab('two_sec', verbose=False).every(seconds=2).run(time_logger, 'two_sec'), 135 | Tab('three_sec', verbose=False).every(seconds=3).run(time_logger, 'three_sec') 136 | ) 137 | with PrintCatcher(stream='stdout') as stdout_catcher: 138 | cron.go(max_seconds=6) 139 | 140 | base_lookup = { 141 | 'three_sec': 3, 142 | 'two_sec': 2, 143 | } 144 | 145 | lines = list(stdout_catcher.text.split('\n')) 146 | 147 | # make sure times fall int right slots 148 | for line in lines: 149 | if line: 150 | words = line.split() 151 | name = words[0] 152 | time = parse('T'.join(words[1:])) 153 | self.assertEqual(time.second % base_lookup[name], 0) 154 | 155 | # make sure the tasks were run the proper number of times 156 | counter = Counter() 157 | for line in lines: 158 | if line: 159 | counter.update({line.split()[0]: 1}) 160 | 161 | self.assertEqual(counter['two_sec'], 3) 162 | self.assertEqual(counter['three_sec'], 2) 163 | 164 | def test_anchored_case(self): 165 | cron = Cron() 166 | starting = datetime.datetime.now() 167 | cron.schedule( 168 | Tab('three_sec', verbose=False).starting(starting).every(seconds=3).run(time_logger, 'three_sec'), 169 | Tab('three_sec_str', verbose=False).starting( 170 | starting.isoformat()).every(seconds=3).run(time_logger, 'three_sec_str'), 171 | ) 172 | with PrintCatcher(stream='stdout') as stdout_catcher: 173 | cron.go(max_seconds=3.5) 174 | 175 | # make sure times fall int right slots 176 | lines = list(stdout_catcher.text.split('\n')) 177 | for line in lines: 178 | if line: 179 | words = line.split() 180 | time = parse('T'.join(words[1:])) 181 | elapsed = (time - starting).total_seconds() 182 | self.assertTrue(elapsed < 3) 183 | 184 | def test_excluding(self): 185 | # Test base case 186 | cron = Cron() 187 | cron.schedule( 188 | Tab('base_case', verbose=True).every(seconds=1).run(time_logger, 'base_case'), 189 | Tab('d+').every(seconds=1).during(return_true).run(time_logger, 'd+'), 190 | Tab('d-').every(seconds=1).during(return_false).run(time_logger, 'd-'), 191 | Tab('e+').every(seconds=1).excluding(return_true).run(time_logger, 'e+'), 192 | Tab('e-').every(seconds=1).excluding(return_false).run(time_logger, 'e-'), 193 | ) 194 | 195 | with PrintCatcher(stream='stdout') as stdout_catcher: 196 | cron.go(max_seconds=2) 197 | 198 | self.assertTrue('d+' in stdout_catcher.text) 199 | self.assertFalse('d-' in stdout_catcher.text) 200 | self.assertFalse('e+' in stdout_catcher.text) 201 | self.assertTrue('e-' in stdout_catcher.text) 202 | 203 | 204 | def return_true(*args, **kwargs): 205 | return True 206 | 207 | 208 | def return_false(*args, **kwargs): 209 | return False 210 | 211 | 212 | def timed_error(then): 213 | now = datetime.datetime.now() 214 | if then + datetime.timedelta(seconds=3) < now < then + datetime.timedelta(seconds=6): 215 | print('timed_error_failure') 216 | raise ExpectedException('This exception is expected in tests. Don\'t worry about it.') 217 | else: 218 | print('timed_error_success') 219 | 220 | 221 | class TestRobustness(TestCase): 222 | def test_robust_case(self): 223 | 224 | then = datetime.datetime.now() 225 | 226 | cron = Cron() 227 | cron.schedule( 228 | Tab('one_sec', verbose=False).every(seconds=1).run(time_logger, 'running_time_logger'), 229 | Tab('two_sec', verbose=False, robust=True).every(seconds=1).run(functools.partial(timed_error, then)) 230 | ) 231 | with PrintCatcher(stream='stdout') as catcher: 232 | cron.go(max_seconds=10) 233 | 234 | success_count = catcher.text.count('timed_error_success') 235 | failure_count = catcher.text.count('timed_error_failure') 236 | time_logger_count = catcher.text.count('running_time_logger') 237 | self.assertEqual(success_count, 7) 238 | self.assertEqual(failure_count, 3) 239 | self.assertEqual(time_logger_count, 10) 240 | 241 | def test_non_robust_case(self): 242 | 243 | then = datetime.datetime.now() 244 | 245 | cron = Cron() 246 | cron.schedule( 247 | Tab('one_sec', verbose=False).every(seconds=1).run(time_logger, 'running_time_logger'), 248 | Tab('two_sec', verbose=False, robust=False).every(seconds=1).run(functools.partial(timed_error, then)) 249 | ) 250 | with PrintCatcher(stream='stdout') as catcher: 251 | cron.go(max_seconds=10) 252 | 253 | success_count = catcher.text.count('timed_error_success') 254 | failure_count = catcher.text.count('timed_error_failure') 255 | time_logger_count = catcher.text.count('running_time_logger') 256 | self.assertEqual(success_count, 3) 257 | self.assertEqual(failure_count, 1) 258 | self.assertEqual(time_logger_count, 10) 259 | 260 | 261 | def func(): 262 | print('func_was_called') 263 | 264 | 265 | class TestStartingOnNextInterval(TestCase): 266 | 267 | def test_starts_on_next(self): 268 | second = 0 269 | interval_seconds = 5 270 | while second % interval_seconds == 0: 271 | now = datetime.datetime.now() 272 | second = now.second 273 | 274 | epoch = fleming.floor(now, second=interval_seconds) 275 | then = epoch + relativedelta(seconds=interval_seconds) 276 | 277 | cron = Cron().schedule( 278 | Tab( 279 | name='pusher', 280 | robust=False, 281 | memory_friendly=False, 282 | ).run( 283 | func, 284 | ).starting( 285 | then 286 | ).every( 287 | seconds=5 288 | ) 289 | ) 290 | 291 | with PrintCatcher(stream='stdout') as catcher: 292 | cron.go(max_seconds=5) 293 | 294 | assert('func_was_called' in catcher.text) 295 | -------------------------------------------------------------------------------- /crontabs/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.2.2' 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " epub to make an epub" 33 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 34 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 35 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 36 | @echo " text to make text files" 37 | @echo " man to make manual pages" 38 | @echo " texinfo to make Texinfo files" 39 | @echo " info to make Texinfo files and run them through makeinfo" 40 | @echo " gettext to make PO message catalogs" 41 | @echo " changes to make an overview of all changed/added/deprecated items" 42 | @echo " xml to make Docutils-native XML files" 43 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 44 | @echo " linkcheck to check all external links for integrity" 45 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 46 | 47 | clean: 48 | rm -rf $(BUILDDIR)/* 49 | 50 | html: 51 | $(SPHINXBUILD) -W -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 52 | @echo 53 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 54 | 55 | dirhtml: 56 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 59 | 60 | singlehtml: 61 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 62 | @echo 63 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 64 | 65 | pickle: 66 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 67 | @echo 68 | @echo "Build finished; now you can process the pickle files." 69 | 70 | json: 71 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 72 | @echo 73 | @echo "Build finished; now you can process the JSON files." 74 | 75 | htmlhelp: 76 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 77 | @echo 78 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 79 | ".hhp project file in $(BUILDDIR)/htmlhelp." 80 | 81 | epub: 82 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 83 | @echo 84 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 85 | 86 | latex: 87 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 88 | @echo 89 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 90 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 91 | "(use \`make latexpdf' here to do that automatically)." 92 | 93 | latexpdf: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo "Running LaTeX files through pdflatex..." 96 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 97 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 98 | 99 | latexpdfja: 100 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 101 | @echo "Running LaTeX files through platex and dvipdfmx..." 102 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 103 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 104 | 105 | text: 106 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 107 | @echo 108 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 109 | 110 | man: 111 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 112 | @echo 113 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 114 | 115 | texinfo: 116 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 117 | @echo 118 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 119 | @echo "Run \`make' in that directory to run these through makeinfo" \ 120 | "(use \`make info' here to do that automatically)." 121 | 122 | info: 123 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 124 | @echo "Running Texinfo files through makeinfo..." 125 | make -C $(BUILDDIR)/texinfo info 126 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 127 | 128 | gettext: 129 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 130 | @echo 131 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 132 | 133 | changes: 134 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 135 | @echo 136 | @echo "The overview file is in $(BUILDDIR)/changes." 137 | 138 | linkcheck: 139 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 140 | @echo 141 | @echo "Link check complete; look for any errors in the above output " \ 142 | "or in $(BUILDDIR)/linkcheck/output.txt." 143 | 144 | doctest: 145 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 146 | @echo "Testing of doctests in the sources finished, look at the " \ 147 | "results in $(BUILDDIR)/doctest/output.txt." 148 | 149 | xml: 150 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 151 | @echo 152 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 153 | 154 | pseudoxml: 155 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 156 | @echo 157 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 158 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | import inspect 4 | import os 5 | import re 6 | import sys 7 | 8 | file_dir = os.path.realpath(os.path.dirname(__file__)) 9 | sys.path.append(os.path.join(file_dir, '..')) 10 | 11 | def get_version(): 12 | """Obtain the packge version from a python file e.g. pkg/__init__.py 13 | See . 14 | """ 15 | file_dir = os.path.realpath(os.path.dirname(__file__)) 16 | with open( 17 | os.path.join(file_dir, '..', 'crontabs', '__init__.py')) as f: 18 | txt = f.read() 19 | version_match = re.search( 20 | r"""^__version__ = ['"]([^'"]*)['"]""", txt, re.M) 21 | if version_match: 22 | return version_match.group(1) 23 | raise RuntimeError("Unable to find version string.") 24 | 25 | 26 | # If extensions (or modules to document with autodoc) are in another directory, 27 | # add these directories to sys.path here. If the directory is relative to the 28 | # documentation root, use os.path.abspath to make it absolute, like shown here. 29 | #sys.path.insert(0, os.path.abspath('.')) 30 | 31 | # -- General configuration ------------------------------------------------ 32 | 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | #'sphinx.ext.intersphinx', 36 | 'sphinx.ext.viewcode', 37 | #'sphinxcontrib.fulltoc', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'toc' 48 | 49 | # General information about the project. 50 | project = 'crontabs' 51 | copyright = '2017, Rob deCarvalho' 52 | 53 | # The short X.Y version. 54 | version = get_version() 55 | # The full version, including alpha/beta/rc tags. 56 | release = version 57 | 58 | exclude_patterns = ['_build'] 59 | 60 | # The name of the Pygments (syntax highlighting) style to use. 61 | pygments_style = 'sphinx' 62 | 63 | intersphinx_mapping = { 64 | 'python': ('http://docs.python.org/3.4', None), 65 | 'django': ('http://django.readthedocs.org/en/latest/', None), 66 | #'celery': ('http://celery.readthedocs.org/en/latest/', None), 67 | } 68 | 69 | # -- Options for HTML output ---------------------------------------------- 70 | 71 | html_theme = 'default' 72 | #html_theme_path = [] 73 | 74 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 75 | if not on_rtd: # only import and set the theme if we're building docs locally 76 | import sphinx_rtd_theme 77 | html_theme = 'sphinx_rtd_theme' 78 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 79 | 80 | # Add any paths that contain custom static files (such as style sheets) here, 81 | # relative to this directory. They are copied after the builtin static files, 82 | # so a file named "default.css" will overwrite the builtin "default.css". 83 | # html_static_path = ['_static'] 84 | html_static_path = [] 85 | 86 | # Custom sidebar templates, maps document names to template names. 87 | #html_sidebars = {} 88 | 89 | # Additional templates that should be rendered to pages, maps page names to 90 | # template names. 91 | #html_additional_pages = {} 92 | 93 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 94 | html_show_sphinx = False 95 | 96 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 97 | html_show_copyright = True 98 | 99 | # Output file base name for HTML help builder. 100 | htmlhelp_basename = 'crontabsdoc' 101 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | crontabs 2 | ============================= 3 | 4 | Replace this text with content. 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/ref/crontabs.rst: -------------------------------------------------------------------------------- 1 | .. _ref-crontabs: 2 | 3 | 4 | API Documentation 5 | ================== 6 | Replace this with api documentation 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/toc.rst: -------------------------------------------------------------------------------- 1 | Table of Contents 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | 7 | index 8 | ref/crontabs 9 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | subprocess.call('pip install wheel'.split()) 4 | subprocess.call('pip install twine'.split()) 5 | subprocess.call('rm -rf ./build'.split()) 6 | subprocess.call('rm -rf ./dist/'.split()) 7 | subprocess.call('python setup.py clean --all'.split()) 8 | subprocess.call('python setup.py sdist bdist_wheel'.split()) 9 | subprocess.call('twine upload dist/*'.split()) 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [coverage:report] 3 | show_missing=True 4 | exclude_lines = 5 | # Have to re-enable the standard pragma 6 | pragma: no cover 7 | 8 | # Don't complain if tests don't hit defensive assertion code: 9 | raise NotImplementedError 10 | 11 | [coverage:run] 12 | omit = 13 | crontabs/version.py 14 | crontabs/__init__.py 15 | */env/* 16 | concurrency = multiprocessing 17 | 18 | 19 | 20 | 21 | [flake8] 22 | max-line-length = 120 23 | exclude = docs,env,*.egg 24 | max-complexity = 13 25 | ignore = E402 26 | 27 | [build_sphinx] 28 | source-dir = docs/ 29 | build-dir = docs/_build 30 | all_files = 1 31 | 32 | [upload_sphinx] 33 | upload-dir = docs/_build/html 34 | 35 | [bdist_wheel] 36 | universal = 1 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # import multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215) 2 | import multiprocessing 3 | assert multiprocessing 4 | import re 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def get_version(): 9 | """ 10 | Extracts the version number from the version.py file. 11 | """ 12 | VERSION_FILE = 'crontabs/version.py' 13 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 14 | if mo: 15 | return mo.group(1) 16 | else: 17 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 18 | 19 | 20 | install_requires = [ 21 | 'fleming', 22 | 'daiquiri[json]' 23 | ] 24 | 25 | tests_require = [ 26 | 'coverage', 27 | 'flake8', 28 | 'mock', 29 | 'pytest', 30 | 'pytest-cov', 31 | 'pytest-xdist', 32 | 'wheel', 33 | ] 34 | 35 | docs_require = [ 36 | # 'Sphinx', 37 | # 'sphinx_rtd_theme' 38 | ] 39 | 40 | extras_require = { 41 | 'dev': tests_require + docs_require, 42 | } 43 | 44 | setup( 45 | name='crontabs', 46 | version=get_version(), 47 | description='Simple job scheduling for python', 48 | long_description='Simple job scheduling for python', 49 | url='https://github.com/robdmc/crontabs', 50 | author='Rob deCarvalho', 51 | author_email='unlisted@unlisted.net', 52 | keywords='', 53 | packages=find_packages(), 54 | classifiers=[ 55 | 'Programming Language :: Python :: 2.7', 56 | 'Programming Language :: Python :: 3.4', 57 | 'Programming Language :: Python :: 3.5', 58 | 'Intended Audience :: Developers', 59 | 'License :: OSI Approved :: MIT License', 60 | 'Operating System :: OS Independent', 61 | ], 62 | license='MIT', 63 | include_package_data=True, 64 | test_suite='nose.collector', 65 | install_requires=install_requires, 66 | tests_require=tests_require, 67 | extras_require=extras_require, 68 | zip_safe=False, 69 | ) 70 | --------------------------------------------------------------------------------