Commits

Meme Dough  committed 98682c6

Now handles quoting the command and refactored heavily.

The command needs to be quoted if it has options, otherwise nosier
will attempt to process the options. However quoting the command
failed due to passing a single string with whitespace to the
subprocess module. This is now fixed.

Refactored heavily to separate the printing to the terminal into the
reporter, all the file monitoring into the change monitor, and all the
command running into the runner.

  • Participants
  • Parent commits ad98f97
  • Tags RELEASE_0_12

Comments (0)

Files changed (5)

 *egg-info
 build
 dist
+env*
 Nosier
 ======
 
-Nosier monitors paths for changes and then runs a specified command.
+Monitors paths and upon detecting changes runs the specified command.
 
 Intended for automatically running tests upon code changes, it can
-however be useful for running any command upon changes to files.
+also be useful for running any command upon changes to files.
 
-Alternatives are Jeff Winkler's original nosy script and Gary
-Bernhardt's improved nosy script.  It appears that both of these are
-not packaged for easy installation.
+Any number of paths may be monitored and directories will be
+recursively monitored.
 
-Nosier can be installed simply with pip::
+Both white list and black list are supported to refine exactly what
+paths are monitored, with the white list taking precedence over the
+black list.
+
+By default an initial command run is performed but this can be turned
+off.
+
+In addition any file changes detected during a command run can be
+discarded at the end of the run to avoid an immediate rerun.
+
+Nosier uses the Linux inotify facility for monitoring paths and as
+such it doesn't put as much load on the cpu and disk compared to
+regularly scanning and calculating checksums.
+
+Further it can act immediately upon file changes rather than waiting
+for the next scan.  By default there is a small delay of 0.1 second
+before performing a command run in order to collect file changes that
+occur very close together.
+
+It is however limited to Linux 2.6 since it depends on the inotify
+facility.
+
+To run py.test upon changes::
+
+    nosier py.test tests
+
+To run nose upon changes::
+
+    nosier nosetests tests
+
+To rsync a project to another host upon changes (note quotes are required here for the options passed to rsync)::
+
+    nosier "rsync -av awesome_project remote_host:/work/area/"
+
+Installation with pip::
 
     pip install nosier
 
-Or with easy_install::
+Installation with easy install::
 
     easy_install nosier
-
-Nosier uses the Linux inotify facility for monitoring paths which has
-a couple of implications.
-
-First, it doesn't put as much load on the cpu and disk as some
-alternatives which will regularly scan and calculate check sums.
-
-Second, inotify is Linux 2.6 specific and hence limited to that
-platform.

File nosier/constants.py

-USAGE = '''\
-Usage: %prog [options] command
+USAGE_TEXT = '''\
+Usage: %prog [options] "command"
 
-Monitor directories and files for changes.  On detecting a change run
-the specified command.'''
+Monitors paths and upon detecting changes runs the specified command.
 
-VERSION_NUMBER = '0.11'
+Ensure the command is quoted to avoid %prog processing options that
+are intended for the command.
 
-VERSION = '%%prog version %s' % VERSION_NUMBER
+Full help displayed with %prog --help'''
+
+VERSION_NUMBER = '0.12'
+
+VERSION_TEXT = '%%prog version %s' % VERSION_NUMBER
 
 BUILTIN_BLACK_LIST = ['.hg', '.git', '.bzr', '.svn', '#*', '.#*', '*.swp']
 

File nosier/nosier.py

 import sys
 import os
 
-WATCH_EVENTS = inotifyx.IN_CREATE | inotifyx.IN_MODIFY | inotifyx.IN_DELETE | inotifyx.IN_DELETE_SELF | inotifyx.IN_MOVE
 
-def iteration_generator():
-    i = 0
-    while True:
-        i += 1
-        yield i
+class Reporter(object):
+    """Responsible for displaying info on the terminal."""
 
-iterations = iteration_generator()
+    def __init__(self):
+        """Creates a new reporter."""
 
+        self.run_number = 0
 
-def run_command(command, fd, watches, events, ignore_events, white_listed):
-    # Run the specified command.
-    print '=' * WIDTH
-    print 'Iteration: %s' % iterations.next()
-    print 'Files    : %s' % ' '.join(set(os.path.join(watches.get(event.wd, ''), event.name or '') for event in events))
-    print 'Command  : %s' % ' '.join(command)
-    print ''
+    def __enter__(self):
+        """Report starting."""
 
-    subprocess.call(command)
+        print 'Setting up monitoring of paths'
+        return self
 
-    print ''
+    def monitor_count(self, count):
+        """Report number of paths monitored."""
 
-    if ignore_events:
-        events = inotifyx.get_events(fd, 0)
-        while events:
-            events = [event for event in events if not event.name or white_listed(event.name)]
+        print 'Monitoring %s paths' % count
+
+    def begin_run(self, change_set, command):
+        """Report the beginning of the run."""
+
+        self.run_number += 1
+        print '=' * WIDTH
+        print 'Run Number : %s' % self.run_number
+        print 'Files      : %s' % ' '.join(change_set)
+        print 'Command    : %s' % ' '.join(command)
+        print
+
+    def end_run(self, ignored_change_set):
+        """Report the end of the run."""
+
+        print
+        if ignored_change_set:
+            print 'Ignoring changed files : %s' % ' '.join(ignored_change_set)
+            print
+        print '-' * WIDTH
+
+    def __exit__(self, e_type, e_value, tb):
+        """Print blank line so shell prompt on clean new line."""
+
+        print
+
+
+class ChangeMonitor(object):
+    """Responsible for detecting files being changed."""
+
+    def __init__(self, paths, white_list, black_list, delay):
+        """Creates a new file change monitor."""
+
+        # Events of interest.
+        self.WATCH_EVENTS = inotifyx.IN_CREATE | inotifyx.IN_MODIFY | inotifyx.IN_DELETE | inotifyx.IN_DELETE_SELF | inotifyx.IN_MOVE
+
+        # Remember params.
+        self.white_list = white_list
+        self.black_list = black_list
+        self.delay = delay
+
+        # Init inotify.
+        self.fd = inotifyx.init()
+
+        # Watch specified paths.
+        self.watches = {}
+        self.watches.update((inotifyx.add_watch(self.fd, path, self.WATCH_EVENTS), path)
+                            for path in paths)
+
+        # Watch sub dirs of specified paths.  Ensure we modify dirs
+        # variable in place so that os.walk only traverses white
+        # listed dirs.
+        for path in paths:
+            for root, dirs, files in os.walk(path):
+                dirs[:] = [dir for dir in dirs if self.is_white_listed(dir)]
+                self.watches.update((inotifyx.add_watch(self.fd, os.path.join(root, dir), self.WATCH_EVENTS), os.path.join(root, dir))
+                                    for dir in dirs)
+
+    def monitor_count(self):
+        """Return number of paths being monitored."""
+
+        return len(self.watches)
+
+    def __iter__(self):
+        """Iterating a monitor returns the next set of changed files.
+
+        When requesting the next item from a monitor it will block
+        until file changes are detected and then return the set of
+        changed files.
+        """
+
+        while True:
+            # Block until events arrive.
+            events = inotifyx.get_events(self.fd)
+
+            # Continue collecting events for the delay period.  This
+            # allows events that occur close to the trigger event to be
+            # collected now rather than causing another run immediately
+            # after this run.
+            end = time.time() + self.delay
+            while time.time() < end:
+                events.extend(inotifyx.get_events(self.fd, 0))
+
+            # Filter to events that are white listed.
+            events = [event for event in events if self.is_white_listed(event.name)]
+
             if events:
-                print 'Ignoring events for files: %s' % ' '.join(set(os.path.join(watches.get(event.wd, ''), event.name or '') for event in events))
-            events = inotifyx.get_events(fd, 0)
-        print ''
+                # Track watched dirs.
+                for event in events:
+                    if event.mask & inotifyx.IN_ISDIR and event.mask & inotifyx.IN_CREATE:
+                        self.watches[inotifyx.add_watch(self.fd, os.path.join(self.watches.get(event.wd), event.name), self.WATCH_EVENTS)] = os.path.join(self.watches.get(event.wd), event.name)
+                    elif event.mask & inotifyx.IN_DELETE_SELF:
+                        self.watches.pop(event.wd, None)
 
-    print '-' * WIDTH
+                # Supply this set of changes to the caller.
+                change_set = set(os.path.join(self.watches.get(event.wd, ''), event.name or '')
+                                 for event in events)
+                yield change_set
+
+    def clear(self):
+        """Clears and returns any changed files that are waiting in the queue."""
+
+        events = inotifyx.get_events(self.fd, 0)
+        change_set = set(os.path.join(self.watches.get(event.wd, ''), event.name or '')
+                         for event in events
+                         if self.is_white_listed(event.name))
+        return change_set
+
+    def is_white_listed(self, name):
+        """Return whether name is in or out."""
+
+        # Events with empty name are in as we have a watch on that
+        # path.
+        if not name:
+            return True
+
+        # Names in white list are always considered in.
+        for pattern in self.white_list:
+            if fnmatch.fnmatch(name, pattern):
+                return True
+
+        # Names in black list are always considered out.
+        for pattern in self.black_list:
+            if fnmatch.fnmatch(name, pattern):
+                return False
+
+        # If not white or black listed then considered in.
+        return True
+
+
+class Runner(object):
+    """Responsible for running a specified command upon file changes."""
+
+    def __init__(self, reporter, change_monitor, ignore_events, no_initial_run, command):
+        """Creates a new command runner."""
+
+        self.reporter = reporter
+        self.change_monitor = change_monitor
+        self.ignore_events = ignore_events
+        self.no_initial_run = no_initial_run
+        self.command = []
+        for part in command:
+            self.command.extend(part.split())
+
+    def do_command(self):
+        """Invoke our command."""
+
+        subprocess.call(self.command)
+
+    def do_run(self, change_set):
+        """Perform a command run."""
+
+        self.reporter.begin_run(change_set, self.command)
+        self.do_command()
+        ignored_change_set = self.change_monitor.clear() if self.ignore_events else set()
+        self.reporter.end_run(ignored_change_set)
+
+    def main_loop(self):
+        """Waits for a set of changed files and then does a command run."""
+
+        # Report number of paths being monitored.
+        self.reporter.monitor_count(self.change_monitor.monitor_count())
+
+        # Do initial command run.
+        if not self.no_initial_run:
+            self.do_run(set())
+
+        # Monitor and run the specified command until keyboard interrupt.
+        for change_set in self.change_monitor:
+            self.do_run(change_set)
 
 
 def main():
+    """Process command line, setup and enter main loop."""
+
     try:
         # Process command line options.
-        parser = optparse.OptionParser(usage=USAGE, version=VERSION)
+        parser = optparse.OptionParser(usage=USAGE_TEXT, version=VERSION_TEXT)
         parser.add_option('-p', '--path', action='append',
                           help='add a path to monitor for changes, if no paths are specified then the current directory will be monitored')
         parser.add_option('-d', '--delay', type='float', default=0.1,
             black_list.extend(BUILTIN_BLACK_LIST)
         no_initial_run = options.no_initial_run
 
-        # Fn that indicates if name is in or out.
-        def white_listed(name):
-            for pattern in white_list:
-                if fnmatch.fnmatch(name, pattern):
-                    return True
-            for pattern in black_list:
-                if fnmatch.fnmatch(name, pattern):
-                    return False
-            return True
+        # Create the reporter that prints info to the terminal.
+        with Reporter() as reporter:
 
-        # Init inotify.
-        fd = inotifyx.init()
+            # Create the monitor that watches for file changes.
+            change_monitor = ChangeMonitor(paths, white_list, black_list, delay)
 
-        # Watch specified paths.
-        print 'Setting up watches on paths'
+            # Create the runner that invokes the command on file
+            # changes.
+            runner = Runner(reporter, change_monitor, ignore_events, no_initial_run, command)
 
-        watches = {}
-        watches.update((inotifyx.add_watch(fd, path, WATCH_EVENTS), path)
-                       for path in paths)
-
-        # Watch sub dirs of specified paths.  Ensure we modify dirs in
-        # place so that os.walk only traverses white listed dirs.
-        for path in paths:
-            for root, dirs, files in os.walk(path):
-                dirs[:] = [dir for dir in dirs if white_listed(dir)]
-                watches.update((inotifyx.add_watch(fd, os.path.join(root, dir), WATCH_EVENTS), os.path.join(root, dir))
-                               for dir in dirs)
-
-        print 'Watching %d paths' % len(watches)
-
-        # Initial command run.
-        if not no_initial_run:
-            run_command(command, fd, watches, [], ignore_events, white_listed)
-
-        # Monitor and run the specified command until keyboard interrupt.
-        while True:
-            # Block until events arrive.
-            events = inotifyx.get_events(fd)
-
-            # Continue collecting events for the delay period.  This
-            # allows events that occur close to the trigger event to be
-            # collected now rather than causing another run immediately
-            # after this run.
-            end = time.time() + delay
-            while time.time() < end:
-                events.extend(inotifyx.get_events(fd, 0))
-
-            # Filter to events that have no name (self destruct watch
-            # events) and to events that have names that are considered
-            # white listed.
-            events = [event for event in events if not event.name or white_listed(event.name)]
-
-            # Track watched dirs.
-            for e in events:
-                if e.mask & inotifyx.IN_ISDIR and e.mask & inotifyx.IN_CREATE:
-                    watches[inotifyx.add_watch(fd, os.path.join(watches.get(e.wd), e.name), WATCH_EVENTS)] = os.path.join(watches.get(e.wd), e.name)
-                elif e.mask & inotifyx.IN_DELETE_SELF:
-                    watches.pop(e.wd, None)
-
-            # Do command run provided we have events after white listing.
-            if events:
-                run_command(command, fd, watches, events, ignore_events, white_listed)
+            # Enter the main loop until we break out.
+            runner.main_loop()
 
     except KeyboardInterrupt:
-        # Exit requested so print blank line to ensure command prompt
-        # will be on a clean new line and exit.
-        print ''
         pass
 
 
 
 setuptools.setup(name='nosier',
                  version=nosier.constants.VERSION_NUMBER,
-                 description='Monitors paths for changes and then runs a specified command',
+                 description='Monitors paths and upon detecting changes runs the specified command',
                  long_description=open('README').read().strip(),
                  author='Meme Dough',
                  author_email='memedough@gmail.com',
-                 url='http://pypi.python.org/pypi/nosier',
+                 url='http://bitbucket.org/memedough/nosier/overview',
                  packages=['nosier'],
                  install_requires=['inotifyx'],
                  entry_points={'console_scripts': ['nosier = nosier.nosier:main']},