├── requirements.txt ├── MANIFEST.in ├── .gitignore ├── CONTRIBUTORS.txt ├── gorun_settings.py.example ├── setup.py ├── LICENSE ├── README.rst └── gorun.py /requirements.txt: -------------------------------------------------------------------------------- 1 | pyinotify==0.9.3 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst requirements.txt LICENSE 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | /build/ 4 | dist/ 5 | gorun.egg-info/ 6 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Peter Bengtsson 2 | Bruno Renié 3 | Gautier Hayoun 4 | Allan Lei 5 | Mathieu Agopian 6 | -------------------------------------------------------------------------------- /gorun_settings.py.example: -------------------------------------------------------------------------------- 1 | # Note! Order matters which makes it possible to run particular 2 | # command if a file changes in a directory and another command for all 3 | # other files in that directory. 4 | 5 | DIRECTORIES = ( 6 | 7 | ('some/place/', './myframework test --dir some/place'), 8 | 9 | ('some/place/unitests.py', 10 | './myframework test --dir some/place --testclass Unittests'), 11 | 12 | (('/path', '/path/sub', '/extra/path/elsewhere'), 13 | 'ping www.fdny.com'), 14 | 15 | ('/var/log/torrentsdownload.log', 16 | 'growl downloads --logfile /var/log/torrentsdownload.log'), 17 | ) 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import setuptools # for zip_safe and install_requires 5 | from distutils.core import setup 6 | 7 | 8 | with open('README.rst') as readme: 9 | long_description = readme.read() 10 | 11 | 12 | with open('requirements.txt') as reqs: 13 | install_requires = [ 14 | line for line in reqs.read().split('\n') if (line and not 15 | line.startswith('--')) 16 | ] 17 | 18 | 19 | setup( 20 | name='gorun', 21 | version='1.6', 22 | description='Wrapper on pyinotify for running commands (often tests)', 23 | long_description=long_description, 24 | author='Peter Bengtsson', 25 | author_email='peter@fry-it.com', 26 | url='http://github.com/peterbe/python-gorun', 27 | license='BSD', 28 | classifiers=[ 29 | 'Programming Language :: Python :: 2', 30 | 'Intended Audience :: Developers', 31 | 'Operating System :: POSIX :: Linux', 32 | 'Topic :: Software Development :: Testing', 33 | 'Topic :: Software Development :: Build Tools', 34 | ], 35 | scripts=['gorun.py'], 36 | zip_safe=False, 37 | install_requires=install_requires, 38 | ) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2012, Peter Bengtsson and contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Peter Bengtsson nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ##### 2 | gorun 3 | ##### 4 | 5 | (c) Peter Bengtsson, mail@peterbe.com, 2009-2012 6 | License: Python 7 | 8 | 9 | Using (py)inotify to run commands when files change 10 | =================================================== 11 | 12 | Tired of switching console, arrow-up, Enter, switch console back for 13 | every little change you make when you're writing code that has tests? 14 | Running with ``gorun.py`` enables you to just save in your editor and 15 | the tests are run automatically and immediately. 16 | 17 | ``gorun.py`` does not use a slow pulling process which keeps taps on 18 | files modification time. Instead it uses the inotify_ which is "a Linux kernel 19 | subsystem that provides file system event notification". 20 | 21 | .. _inotify: http://en.wikipedia.org/wiki/Inotify 22 | 23 | 24 | Installation 25 | ------------ 26 | 27 | This will only work on Linux which has the inotify module enabled in 28 | the kernel. (Most modern kernels do) 29 | 30 | :: 31 | 32 | pip install gorun 33 | 34 | This will install pyinotify_. 35 | 36 | .. _pyinotify: http://trac.dbzteam.org/pyinotify 37 | 38 | Then, create a settings file, which is just a python file that is expected to 39 | define a variable called ``DIRECTORIES``. Here's an example: 40 | 41 | :: 42 | 43 | DIRECTORIES = ( 44 | ('some/place/', './myframework test --dir some/place'), 45 | ('some/place/unitests.py', 46 | './myframework test --dir some/place --testclass Unittests'), 47 | ('/var/log/torrentsdownload.log', 48 | 'growl downloads --logfile /var/log/torrentsdownload.log'), 49 | ) 50 | 51 | Save that file as, for example, ``gorun_settings.py`` and then start it 52 | like this: 53 | 54 | :: 55 | 56 | $ gorun.py gorun_settings 57 | 58 | Configuration 59 | ------------- 60 | 61 | Once you've set gorun to monitor a directory it will kick off on any 62 | file that changes in that directory. By default things like autosave 63 | files from certain editors are automatically created (e.g. #foo.py# or 64 | foo.py~) and these are ignored. If there are other file extensions you 65 | want gorun to ignore add this to your settings file: 66 | 67 | :: 68 | 69 | IGNORE_EXTENSIONS = ('log',) 70 | 71 | This will add to the list of already ignored file extensions such as 72 | ``.pyc``. 73 | 74 | Similarly, if there are certain directories that you don't want the 75 | inotify to notice, you can list them like this: 76 | 77 | :: 78 | 79 | IGNORE_DIRECTORIES = ('xapian_index', '.autosavefiles') 80 | 81 | Disclaimer 82 | ---------- 83 | 84 | This code hasn't been extensively tested and relies on importing 85 | python modules so don't let untrusted morons fiddle with your dev 86 | environment. 87 | 88 | Todo 89 | ---- 90 | 91 | When doing Django development I often run on single test method over 92 | and over and over again till I get rid of all errors. When doing this 93 | I have to change the settings so it just runs one single test and when 94 | I'm done I go back to set it up so that it runs all tests when adjacent 95 | code works. 96 | 97 | This is a nuisance and I might try to solve that one day. If you have 98 | any tips please let me know. 99 | -------------------------------------------------------------------------------- /gorun.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Wrapper on pyinotify for running commands 4 | # (c) 2009 Peter Bengtsson, peter@fry-it.com 5 | # 6 | # TODO: Ok, now it does not start a command while another is runnnig 7 | # But! then what if you actually wanted to test a modification you 8 | # saved while running another test 9 | # Yes, we could stop the running command and replace it by the new test 10 | # But! django tests will complain that a test db is already here 11 | 12 | import os 13 | 14 | from subprocess import Popen 15 | from threading import Lock, Thread 16 | 17 | __version__='1.6' 18 | 19 | class SettingsClass(object): 20 | VERBOSE = False 21 | 22 | settings = SettingsClass() 23 | 24 | try: 25 | from pyinotify import WatchManager, Notifier, ThreadedNotifier, ProcessEvent, EventsCodes 26 | except ImportError: 27 | print "pyinotify not installed. Try: easy_install pyinotify" 28 | raise 29 | 30 | 31 | def _find_command(path): 32 | # path is a file 33 | assert os.path.isfile(path) 34 | # in dictionary lookup have keys as files and directories. 35 | # if this path exists in there, it's a simple match 36 | try: 37 | return lookup[path] 38 | except KeyError: 39 | pass 40 | # is the parent directory in there? 41 | while path != '/': 42 | path = os.path.dirname(path) 43 | try: 44 | return lookup[path] 45 | except KeyError: 46 | pass 47 | 48 | def _ignore_file(path): 49 | if path.endswith('.pyc'): 50 | return True 51 | if path.endswith('~'): 52 | return True 53 | basename = os.path.basename(path) 54 | if basename.startswith('.#'): 55 | return True 56 | if basename.startswith('#') and basename.endswith('#'): 57 | return True 58 | if '.' in os.path.basename(path) and \ 59 | basename.split('.')[-1] in settings.IGNORE_EXTENSIONS: 60 | return True 61 | if os.path.split(os.path.dirname(path))[-1] in settings.IGNORE_DIRECTORIES: 62 | return True 63 | if not os.path.isfile(path): 64 | return True 65 | 66 | class PTmp(ProcessEvent): 67 | 68 | def __init__(self): 69 | super(PTmp, self).__init__() 70 | self.lock = Lock() 71 | 72 | def process_IN_CREATE(self, event): 73 | if os.path.basename(event.pathname).startswith('.#'): 74 | # backup file 75 | return 76 | print "Creating:", event.pathname 77 | command = _find_command(event.pathname) 78 | 79 | #def process_IN_DELETE(self, event): 80 | # print "Removing:", event.pathname 81 | # command = _find_command(event.pathname) 82 | 83 | def process_IN_MODIFY(self, event): 84 | if _ignore_file(event.pathname): 85 | return 86 | 87 | def execute_command(event, lock): 88 | # By default trying to acquire a lock is blocking 89 | # In this case it will create a queue of commands to run 90 | # 91 | # If you try to acquire the lock in the locked state non-blocking 92 | # style, it will immediatly returns False and you know that a 93 | # command is already running, and in this case we don't want to run 94 | # this command at all. 95 | block = settings.RUN_ON_EVERY_EVENT 96 | if not lock.acquire(block): 97 | # in this case we just want to not execute the command 98 | return 99 | print "Modifying:", event.pathname 100 | command = _find_command(event.pathname) 101 | if command: 102 | if settings.VERBOSE: 103 | print "Command: ", 104 | print command 105 | p = Popen(command, shell=True) 106 | sts = os.waitpid(p.pid, 0) 107 | lock.release() 108 | 109 | command_thread = Thread(target=execute_command, args=[event, self.lock]) 110 | command_thread.start() 111 | 112 | 113 | def start(actual_directories): 114 | 115 | wm = WatchManager() 116 | flags = EventsCodes.ALL_FLAGS 117 | mask = flags['IN_MODIFY'] #| flags['IN_CREATE'] 118 | 119 | p = PTmp() 120 | notifier = Notifier(wm, p) 121 | 122 | for actual_directory in actual_directories: 123 | print "DIRECTORY", actual_directory 124 | wdd = wm.add_watch(actual_directory, mask, rec=True) 125 | 126 | # notifier = Notifier(wm, p, timeout=10) 127 | try: 128 | print "Waiting for stuff to happen..." 129 | notifier.loop() 130 | except KeyboardInterrupt: 131 | pass 132 | 133 | return 0 134 | 135 | lookup = {} 136 | 137 | def configure_more(directories): 138 | actual_directories = set() 139 | 140 | #print "directories", directories 141 | 142 | # Tune the configured directories a bit 143 | for i, (path, cmd) in enumerate(directories): 144 | if isinstance(path, (list, tuple)): 145 | actual_directories.update(configure_more( 146 | [(x, cmd) for x in path])) 147 | continue 148 | if not path.startswith('/'): 149 | path = os.path.join(os.path.abspath(os.path.dirname('.')), path) 150 | if not (os.path.isfile(path) or os.path.isdir(path)): 151 | raise OSError, "%s neither a file or a directory" % path 152 | path = os.path.normpath(path) 153 | if os.path.isdir(path): 154 | if path.endswith('/'): 155 | # tidy things up 156 | path = path[:-1] 157 | if path == '.': 158 | path = '' 159 | actual_directories.add(path) 160 | else: 161 | # because we can't tell pyinotify to monitor files, 162 | # when a file is configured, add it's directory 163 | actual_directories.add(os.path.dirname(path)) 164 | 165 | lookup[path] = cmd 166 | 167 | return actual_directories 168 | 169 | 170 | if __name__=='__main__': 171 | import sys 172 | import imp 173 | args = sys.argv[1:] 174 | if not args and os.path.isfile('gorun_settings.py'): 175 | print >>sys.stderr, "Guessing you want to use gorun_settings.py" 176 | args = ['gorun_settings.py'] 177 | if not args and os.path.isfile('gorunsettings.py'): 178 | print >>sys.stderr, "Guessing you want to use gorunsettings.py" 179 | args = ['gorunsettings.py'] 180 | if not args: 181 | print >>sys.stderr, "USAGE: %s importable_py_settings_file" %\ 182 | __file__ 183 | sys.exit(1) 184 | 185 | 186 | settings_file = args[-1] 187 | 188 | sys.path.append(os.path.abspath(os.curdir)) 189 | x = imp.load_source('gorun_settings', settings_file) 190 | settings.DIRECTORIES = x.DIRECTORIES 191 | settings.VERBOSE = getattr(x, 'VERBOSE', settings.VERBOSE) 192 | settings.IGNORE_EXTENSIONS = getattr(x, 'IGNORE_EXTENSIONS', tuple()) 193 | settings.IGNORE_DIRECTORIES = getattr(x, 'IGNORE_DIRECTORIES', tuple()) 194 | settings.RUN_ON_EVERY_EVENT = getattr(x, 'RUN_ON_EVERY_EVENT', False) 195 | actual_directories = configure_more(settings.DIRECTORIES) 196 | 197 | sys.exit(start(actual_directories)) 198 | --------------------------------------------------------------------------------