# # = directory_watcher.rb # # See DirectoryWatcher for detailed documentation and usage. # require 'observer' require 'yaml' # == Synopsis # # A class for watching files within a directory and generating events when # those files change. # # == Details # # A directory watcher is an +Observable+ object that sends events to # registered observers when file changes are detected within the directory # being watched. # # The directory watcher operates by scanning the directory at some interval # and creating a list of the files it finds. File events are detected by # comparing the current file list with the file list from the previous scan # interval. Three types of events are supported -- *added*, *modified*, and # *removed*. # # An added event is generated when the file appears in the current file # list but not in the previous scan interval file list. A removed event is # generated when the file appears in the previous scan interval file list # but not in the current file list. A modified event is generated when the # file appears in the current and the previous interval file list, but the # file modification time or the file size differs between the two lists. # # The file events are collected into an array, and all registered observers # receive all file events for each scan interval. It is up to the individual # observers to filter the events they are interested in. # # === File Selection # # The directory watcher uses glob patterns to select the files to scan. The # default glob pattern will select all regular files in the directory of # interest '*'. # # Here are a few useful glob examples: # # '*' => all files in the current directory # '**/*' => all files in all subdirectories # '**/*.rb' => all ruby files # 'ext/**/*.{h,c}' => all C source code files # # *Note*: file events will never be generated for directories. Only regular # files are included in the file scan. # # === Stable Files # # A fourth file event is supported but not enabled by default -- the # *stable* event. This event is generated after a file has been added or # modified and then remains unchanged for a certain number of scan # intervals. # # To enable the generation of this event the +stable+ count must be # configured. This is the number of scan intervals a file must remain # unchanged (based modification time and file size) before it is considered # stable. # # To disable this event the +stable+ count should be set to +nil+. # # == Usage # # Learn by Doing -- here are a few different ways to configure and use a # directory watcher. # # === Basic # # This basic recipe will watch all files in the current directory and # generate the three default events. We'll register an observer that simply # prints the events to standard out. # # require 'directory_watcher' # # dw = DirectoryWatcher.new '.' # dw.add_observer {|*args| args.each {|event| puts event}} # # dw.start # gets # when the user hits "enter" the script will terminate # dw.stop # # === Suppress Initial "added" Events # # This little twist will suppress the initial "added" events that are # generated the first time the directory is scanned. This is done by # pre-loading the watcher with files -- i.e. telling the watcher to scan for # files before actually starting the scan loop. # # require 'directory_watcher' # # dw = DirectoryWatcher.new '.', :pre_load => true # dw.glob = '**/*.rb' # dw.add_observer {|*args| args.each {|event| puts event}} # # dw.start # gets # when the user hits "enter" the script will terminate # dw.stop # # There is one catch with this recipe. The glob pattern must be specified # before the pre-load takes place. The glob pattern can be given as an # option to the constructor: # # dw = DirectoryWatcher.new '.', :glob => '**/*.rb', :pre_load => true # # The other option is to use the reset method: # # dw = DirectoryWatcher.new '.' # dw.glob = '**/*.rb' # dw.reset true # the +true+ flag causes the watcher to pre-load # # the files # # === Generate "stable" Events # # In order to generate stable events, the stable count must be specified. In # this example the interval is set to 5.0 seconds and the stable count is # set to 2. Stable events will only be generated for files after they have # remain unchanged for 10 seconds (5.0 * 2). # # require 'directory_watcher' # # dw = DirectoryWatcher.new '.', :glob => '**/*.rb' # dw.interval = 5.0 # dw.stable = 2 # dw.add_observer {|*args| args.each {|event| puts event}} # # dw.start # gets # when the user hits "enter" the script will terminate # dw.stop # # === Persisting State # # A directory watcher can be configured to persist its current state to a # file when it is stopped and to load state from that same file when it # starts. Setting the +persist+ value to a filename will enable this # feature. # # require 'directory_watcher' # # dw = DirectoryWatcher.new '.', :glob => '**/*.rb' # dw.interval = 5.0 # dw.persist = "dw_state.yml" # dw.add_observer {|*args| args.each {|event| puts event}} # # dw.start # loads state from dw_state.yml # gets # when the user hits "enter" the script will terminate # dw.stop # stores state to dw_state.yml # # === Running Once # # Instead of using the built in run loop, the directory watcher can be run # one or many times using the +run_once+ method. The state of the directory # watcher can be loaded and dumped if so desired. # # dw = DirectoryWatcher.new '.', :glob => '**/*.rb' # dw.persist = "dw_state.yml" # dw.add_observer {|*args| args.each {|event| puts event}} # # dw.load! # loads state from dw_state.yml # dw.run_once # sleep 5.0 # dw.run_once # dw.persist! # stores state to dw_state.yml # # === Scanning Strategies # # By default DirectoryWatcher uses a thread that scans the directory being # watched for files and calls "stat" on each file. The stat information is # used to determine which files have been modified, added, removed, etc. # This approach is fairly intensive for short intervals and/or directories # with many files. # # DirectoryWatcher supports using Rev () or EventMachine () instead of a # busy polling thread. These libraries use system level kernel hooks to # receive notifications of file system changes. This makes DirectoryWorker # much more efficient. # # This example will use Rev to generate file notifications. # # dw = DirectoryWatcher.new '.', :glob => '**/*.rb', :scanner => :rev # dw.add_observer {|*args| args.each {|event| puts event}} # # dw.start # gets # when the user hits "enter" the script will terminate # dw.stop # # The scanner cannot be changed after the DirectoryWatcher has been # created. To use an EventMachine scanner, pass :em as the :scanner # option. # # == Contact # # A lot of discussion happens about Ruby in general on the ruby-talk # mailing list (http://www.ruby-lang.org/en/ml.html), and you can ask # any questions you might have there. I monitor the list, as do many # other helpful Rubyists, and you're sure to get a quick answer. Of # course, you're also welcome to email me (Tim Pease) directly at the # at tim.pease@gmail.com, and I'll do my best to help you out. # # (the above paragraph was blatantly stolen from Nathaniel Talbott's # Test::Unit documentation) # # == Author # # Tim Pease # class DirectoryWatcher VERSION = '1.3.1' # :nodoc: # An +Event+ structure contains the _type_ of the event and the file _path_ # to which the event pertains. The type can be one of the following: # # :added => file has been added to the directory # :modified => file has been modified (either mtime or size or both # have changed) # :removed => file has been removed from the directory # :stable => file has stabilized since being added or modified # Event = Struct.new(:type, :path) { def to_s( ) "#{type} '#{path}'" end } # :stopdoc: # A persistable file stat structure used internally by the directory # watcher. # FileStat = Struct.new(:mtime, :size, :stable) { def eql?( other ) return false unless other.instance_of? FileStat self.mtime == other.mtime and self.size == other.size end alias :== :eql? } # :startdoc: # call-seq: # DirectoryWatcher.new( directory, options ) # # Create a new +DirectoryWatcher+ that will generate events when file # changes are detected in the given _directory_. If the _directory_ does # not exist, it will be created. The following options can be passed to # this method: # # :glob => '*' file glob pattern to restrict scanning # :interval => 30.0 the directory scan interval (in seconds) # :stable => nil the number of intervals a file must remain # unchanged for it to be considered "stable" # :pre_load => false setting this option to true will pre-load the # file list effectively skipping the initial # round of file added events that would normally # be generated (glob pattern must also be # specified otherwise odd things will happen) # :persist => file the state will be persisted to and restored # from the file when the directory watcher is # stopped and started (respectively) # :scanner => nil the directory scanning strategy to use with # the directory watcher (either :em :rev or nil) # # The default glob pattern will scan all files in the configured directory. # Setting the :stable option to +nil+ will prevent stable events from being # generated. # def initialize( directory, opts = {} ) @dir = directory @observer_peers = {} if Kernel.test(?e, @dir) unless Kernel.test(?d, @dir) raise ArgumentError, "'#{@dir}' is not a directory" end else Dir.mkdir @dir end klass = opts[:scanner].to_s.capitalize + 'Scanner' klass = DirectoryWatcher.const_get klass rescue Scanner @scanner = klass.new {|events| notify_observers(events)} self.glob = opts[:glob] || '*' self.interval = opts[:interval] || 30 self.stable = opts[:stable] || nil self.persist = opts[:persist] @scanner.reset opts[:pre_load] end # call-seq: # add_observer( observer, func = :update ) # add_observer {|*events| block} # # Adds the given _observer_ as an observer on this directory watcher. The # _observer_ will now receive file events when they are generated. The # second optional argument specifies a method to notify updates, of which # the default value is +update+. # # Optionally, a block can be passed as the observer. The block will be # executed with the file events passed as the arguments. A reference to the # underlying +Proc+ object will be returned for use with the # +delete_observer+ method. # def add_observer( observer = nil, func = :update, &block ) unless block.nil? observer = block.to_proc func = :call end unless observer.respond_to? func raise NoMethodError, "observer does not respond to `#{func.to_s}'" end @observer_peers[observer] = func observer end # Delete +observer+ as an observer of this directory watcher. It will no # longer receive notifications. # def delete_observer( observer ) @observer_peers.delete observer end # Delete all observers associated with the directory watcher. # def delete_observers @observer_peers.clear end # Return the number of observers associated with this directory watcher.. # def count_observers @observer_peers.size end # call-seq: # glob = '*' # glob = ['lib/**/*.rb', 'test/**/*.rb'] # # Sets the glob pattern that will be used when scanning the directory for # files. A single glob pattern can be given or an array of glob patterns. # def glob=( val ) glob = case val when String; [File.join(@dir, val)] when Array; val.flatten.map! {|g| File.join(@dir, g)} else raise(ArgumentError, 'expecting a glob pattern or an array of glob patterns') end glob.uniq! @scanner.glob = glob end attr_reader :glob # Sets the directory scan interval. The directory will be scanned every # _interval_ seconds for changes to files matching the glob pattern. # Raises +ArgumentError+ if the interval is zero or negative. # def interval=( val ) val = Float(val) raise ArgumentError, "interval must be greater than zero" if val <= 0 @scanner.interval = Float(val) end # Returns the directory scan interval in seconds. # def interval @scanner.interval end # Sets the number of intervals a file must remain unchanged before it is # considered "stable". When this condition is met, a stable event is # generated for the file. If stable is set to +nil+ then stable events # will not be generated. # # A stable event will be generated once for a file. Another stable event # will only be generated after the file has been modified and then remains # unchanged for _stable_ intervals. # # Example: # # dw = DirectoryWatcher.new( '/tmp', :glob => 'swap.*' ) # dw.interval = 15.0 # dw.stable = 4 # # In this example, a directory watcher is configured to look for swap files # in the /tmp directory. Stable events will be generated every 4 scan # intervals iff a swap remains unchanged for that time. In this case the # time is 60 seconds (15.0 * 4). # def stable=( val ) if val.nil? @scanner.stable = nil return end val = Integer(val) raise ArgumentError, "stable must be greater than zero" if val <= 0 @scanner.stable = val end # Returs the number of intervals a file must remain unchanged before it is # considered "stable". # def stable @scanner.stable end # Sets the name of the file to which the directory watcher state will be # persisted when it is stopped. Setting the persist filename to +nil+ will # disable this feature. # def persist=( filename ) @persist = filename ? filename.to_s : nil end attr_reader :persist # Write the current state of the directory watcher to the persist file. # This method will do nothing if the directory watcher is running or if # the persist file is not configured. # def persist! return if running? File.open(@persist, 'w') {|fd| fd.write YAML.dump(@scanner.files)} if @persist self end # Loads the state of the directory watcher from the persist file. This # method will do nothing if the directory watcher is running or if the # persist file is not configured. # def load! return if running? @scanner.files = YAML.load_file(@persist) if @persist and test(?f, @persist) self end # Returns +true+ if the directory watcher is currently running. Returns # +false+ if this is not the case. # def running? @scanner.running? end # Start the directory watcher scanning thread. If the directory watcher is # already running, this method will return without taking any action. # def start return self if running? load! @scanner.start self end # Stop the directory watcher scanning thread. If the directory watcher is # already stopped, this method will return without taking any action. # def stop return self unless running? @scanner.stop self ensure persist! end # call-seq: # reset( pre_load = false ) # # Reset the directory watcher state by clearing the stored file list. If # the directory watcher is running, it will be stopped, the file list # cleared, and then restarted. Passing +true+ to this method will cause # the file list to be pre-loaded after it has been cleared effectively # skipping the initial round of file added events that would normally be # generated. # def reset( pre_load = false ) was_running = @scanner.running? stop if was_running File.delete(@persist) if @persist and test(?f, @persist) @scanner.reset pre_load start if was_running self end # call-seq: # join( limit = nil ) # # If the directory watcher is running, the calling thread will suspend # execution and run the directory watcher thread. This method does not # return until the directory watcher is stopped or until _limit_ seconds # have passed. # # If the directory watcher is not running, this method returns immediately # with +nil+. # def join( limit = nil ) @scanner.join limit end # Performs exactly one scan of the directory for file changes and notifies # the observers. # def run_once @scanner.run_once self end private # Invoke the update method of each registered observer in turn passing the # list of file events to each. # def notify_observers( events ) @observer_peers.each do |observer, func| begin; observer.send(func, *events); rescue Exception; end end end end # class DirectoryWatcher begin $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__))) require 'directory_watcher/scanner' require 'directory_watcher/em_scanner' require 'directory_watcher/rev_scanner' ensure $LOAD_PATH.shift end # EOF