Err the Blog Atom Feed Icon
Err the Blog
Rubyisms and Railities
  • “Evil Twin Plugin”
    – Chris on November 20, 2007

    Seriously, I think I have something against Rails’ lib directory. We jumped from keeping gems in lib to vendor/gems back in March. Then we jumped from keeping generic Rake tasks in lib/tasks to Sake. Now we’re gonna jump again.

    Hacking Plugins

    It’s really not that big of a deal, and pretty common—you want to change the behavior of some existing plugin. Maybe you Piston it and commit your changes. Sure. But maybe you just want to leave the original code alone.

    A classic approach has been to stick some kind of hack in the lib directory. Issues abound, for sure. First: the load order. Who gets loaded first? Who reloads and who doesn’t? Second: location. You’ve got one bit of code messing with another bit of code in a totally separate location. Third: testing. Are you testing it? Maybe.

    None of these things are deal breakers, but we can certainly address them. And we will.

    The Evil Twin Plugin

    Evil Twin

    Here’s the simple solution: create a plugin called whatever_hacks, where whatever is the name of the plugin you’re hacking. That’s it. An evil twin, if you will.

    Adding the _hacks suffix ensures it will always be loaded after the target plugin (assuming you haven’t messed with the default plugin load order—alphabetical). Keeping it right next to the target plugin also ensures anyone who peers into vendor/plugins will instantly know tomfoolery is afoot.

    You can now build out a tested, hack happy plugin. Or, y’know, just stick it all in init.rb. With caution.

    Caution: init.rb

    Caution: init.rb does not always do what you expect it to do. It’s loaded in the context of Rails::Plugin in 2.0 and Rails::Initializer in 1.2.5, not Object. Come again? Like this: re-opening existing classes isn’t as straightforward as elsewhere.

    => init.rb
    class Hash
    end
    puts Hash.inspect
    

    Guess what that prints. Ready?

    $ ./script/runner
    Rails::Plugin::Hash
    

    That’s right—we didn’t re-open Hash, we created a new Rails::Plugin::Hash class. Any methods we add in there won’t be added to Hash proper.

    If we want to grab a real class and stuff some methods in it, we need to use class_eval or module_eval:

    => init.rb
    Hash.class_eval do
      def duck_punched?
        true
      end
    end
    
    puts({}.duck_punched?)
    

    As expected:

    $ ./script/runner
    true
    

    Doing it this way (class_eval) forces a constant lookup, making Ruby happily run up the chain and find the class or module in question.

    attachment_fu_cropper

    Okay, time for a real example. I wanted to change attachment_fu’s ImageScienceProcessor to crop thumbnails before resizing them. As this is a hack I use on all my apps, I also want to keep it out of my models. Hence, attachment_fu_hacks.

    => vendor/plugins/attachment_fu_hacks/init.rb
    klass = Technoweenie::AttachmentFu::Processors::ImageScienceProcessor
    klass.module_eval do
      ##
      # Hacked to use image_science's #cropped_thumbnail method
      def resize_image(img, size)
        # create a dummy temp file to write to
        filename.sub! /gif$/, 'png'
        self.temp_path = write_to_temp_file(filename)
        grab_dimensions = lambda do |img|
          self.width  = img.width  if respond_to?(:width)
          self.height = img.height if respond_to?(:height)
          img.save temp_path
          callback_with_args :after_resize, img
        end
    
        size = size.first if size.is_a?(Array) && size.length == 1
        if size.is_a?(Fixnum) ||
            (size.is_a?(Array) && size.first.is_a?(Fixnum))
          if size.is_a?(Fixnum)
            img.cropped_thumbnail(size, &grab_dimensions)
          else
            img.cropped_thumbnail(size.first, &grab_dimensions)
          end
        else
          new_size = [img.width, img.height].dim size.to_s
    
          img.cropped_thumbnail(new_size.first, &grab_dimensions)
        end
      end
    end
    

    Works like a charm.

    When heavysixer wanted to hack acts_as_taggable, he took the same approach: http://pastie.caboo.se/119904. Feel free to follow suit.

  • Garry, about 1 hour later:

    Ah, neat tip. I like that putting “_hacks” at the end ensures it loads after the real plugin, load order being alphabetical and so on. I hadn’t thought of that!

  • Jon Maddox, about 1 hour later:

    Hilarious. As I was reading through this, I was thinking about the patch I did for cropping with Attachment_fu. Creepy.

    This is sweet, I’ll try to factor out my patch using this method. I’ve never been a big fan of patching plugins directly, especially if you need to reuse what you did to it. This keeps it dry and distributable. Pretty slick.

  • d, about 1 hour later:

    im hacking our acts_as_taggable_ext, and this flies into my rss reader. cue twilight zone music.

  • Sebastian, about 1 hour later:

    We have a separate RAILS_ROOT/plugins directory for our “own” plugins (and pluginized code, to keep lib clean).

    We also have a “snippets” plugin that mimics the ActiveSupport Railties folders, with names for each object or library we are patching, and a simple init.rb that loads all files inside lib:

    Dir[File.dirname(FILE) + ”/lib/*/.rb”].each { |file| require(file) }}

    There we store dozens of patches and little tweaks to ruby, rails and other plugins.

    Having a separate whatever_hacks plugins makes sense if the changes are significant, but I rather have a single plugin for trivial snippets.

  • David Goodlad, about 4 hours later:

    Instead of the whole class_eval business, can’t you just do:

    class ::Hash def foo; ‘bar’; end end

  • Chris, about 6 hours later:

    ::Hash? Gross!

  • Mislav, about 7 hours later:

    “Gross, LOL :)

    Remember when I wrote” in will_paginate? One of you guys couldn’t stand it :D

  • Mislav, about 7 hours later:

    I said remember when I wrote ::Hash

    Textile sucks, seriously

  • Chris, about 7 hours later:

    Textile? Gross!

  • Garry, about 10 hours later:

    LOL

  • Ryan Platte, about 16 hours later:

    The issues with init.rb are due to its weird implementation within Rails involving eval. I recommend running nothing but requires from within init.rb to make everything nice and clean—required files work as expected with no fallout that I’m aware of from the eval.

  • Jim, about 18 hours later:

    A little off topic, but how do you use your Attachment_fu hack so that you can get square (cropped) thumbnails?

  • Carl, 2 days later:

    Jim, all you have to do is put that code in the attachment_fu_hack dir as init.rb. When I tried it I got ‘undefined method `dim’ for Array’ error. I haven’t looked too closely but he probably just method that attachment_fu defines in Array. Chris may want to correct me on that though.

  • rick, 4 days later:

    I love that you give these old conventions cool names. I made the original commit to rails that orders the plugins before loading them to make this kind of thing possible. Then, someone made a hacked version of acts_as_versioned that worked with associated models or something. Cool, hacky shit.

  • topfunky, 23 days later:

    I was using lib/duck_punches, but this makes much more sense.

  • punkrats, 2 months later:

    Jim, just replace one line, and it’ll work:

    new_size = [img.width, img.height] / size.to_s

    Cheers.

  • , 3 months later:

    So this technique totally overwrites whatever method you’re extending?

    For example, you hack resize_image above.

    You have have to implement this whole method, you can just edit a few args and call super(img, new_size) and have the original resize_image finish.

    Would this be correct?

  • Ade, 5 months later:

    Here’s a version that uses RMagick and also supports desaturation:

    (I hope I get the textile

     tags right!)
    
    <pre>
    klass = Technoweenie::AttachmentFu::Processors::RmagickProcessor
    klass.module_eval do
      ##
      # Hacked to allow cropping
        # see comment by Martyn Loughran at http://toolmantim.com/article/2006/9/12/generating_cropped_thumbnails_with_acts_as_attachment
        def resize_image(img, size)
        size = size.first if size.is_a?(Array) && size.length == 1 && !size.first.is_a?(Fixnum)
        if size.is_a?(Fixnum) || (size.is_a?(Array) && size.first.is_a?(Fixnum))
          size = [size, size] if size.is_a?(Fixnum)
          img.thumbnail!(*size)
        # This elsif extends
            # extended again by Adrian Duyzer to support desaturation
        elsif size.is_a?(Hash)
                img = img.quantize(256, Magick::GRAYColorspace) if size[:desaturate]
          dx, dy = size[:crop].split(':').map(&:to_f)
          w, h = (img.rows * dx / dy), (img.columns * dy / dx)
          img.crop!(::Magick::CenterGravity, [img.columns, w].min, [img.rows, h].min)
          size = size[:size]
          w2, h2 = size.split('x').map(&:to_f)
          img.resize!(w2,h2)
        else
          img.change_geometry(size.to_s) { |cols, rows, image| image.resize!(cols, rows) }
        end
        self.temp_path = write_to_temp_file(img.to_blob)
      end
    end
    </pre>
    
    Use like so (note the different way that thumbnail sizes are specified from the main size):
    
    <pre>
        has_attachment :content_type => :image,
                     :storage => :file_system,
                     :max_size => 5.megabytes,
                     :resize_to => {:crop => "375:430", :size => '375x430', :desaturate => true},
                     :thumbnails => {
                                         :medium => [:crop => "185:205", :size => '185x205', :desaturate => true],
                                         :small => [:crop => "1:1", :size => '100x100', :desaturate => true]
                                     }
    </pre>

  • Ade, 5 months later:

    Annnd…I got the textile tags wrong. Maybe you guys can fix it up for me.

  • Nineteen people have commented.
    Chime in.
    Sorry, no more comments :(
This is Err, the weblog of PJ Hyett and Chris Wanstrath.
All original content copyright ©2006-2008 the aforementioned.