Every now and then, a library you’re using just doesn’t quite work. There’s a bug and a fix hasn’t been made available yet. You can add a work-around, but you can also automate its removal.
A simple example is Liquid’s
date filter. It used to be possible to do this:
The output would be the current year as a number, such as 2013. Then for some reason it stopped working. The reason was that in Ruby 1.8.7, you could pass
Time.parse, and the Time library in stdlib would do what you expect.
require "time" Time.parse("now") #=> Thu Mar 14 16:33:05 +0000 2013
It stopped working because later versions of Ruby don’t allow it.
require "time" Time.parse("now") #=> ArgumentError: no time information in "now"
You definitely don’t want to patch
Time.parse itself — that’s a costly assumption to add — but being able to use
"now" for Liquid’s
date filter to fix the regression is useful, and it’s something they’ll likely fix anyway. You can patch it easily enough yourself.
module Liquid::StandardFilters alias_method :date_orig, :date def date(input, format) input == "now" ? date_orig(Time.now, format) : date_orig(input, format) end end
All is mostly well. But this type of patch is meant to be temporary.
If the problem is resolved upstream — in this case, if support for
"now" is merged and released into a new version — the work-around is useless clutter. Worse, for non-trivial patches, your code will be buggy, and thus worse than useless: the library maintainers will have dealt with edge cases you can’t foresee. So you’re now maintaining a very “tiny” private fork through one or more patches, and the code is useless.
How do you deal with that? It’s easy. You just remember every work-around you’ve added and stay aware of all changes to all libraries you’ve patched. Then you’ll know when to remove the patch when it’s no longer needed.
Or you can automate it with a couple of good principles.
- Make it become redundant automatically.
- Ensure you automatically find out when it’s redundant.
Let’s return to the
date example to see what this means concretely.
The original stimulus in adding the patch was, in this case, because
2017 wasn’t working. So the patch needs to become redundant when support is once again baked in. Wrap it in a conditional:
if !date_is_supported module Liquid::StandardFilters alias_method :date_orig, :date def date(input, format) input == "now" ? date_orig(Time.now, format) : date_orig(input, format) end end end
Leaving aside the details of how
date_is_supported works, as soon as Liquid’s original
date filter starts accepting
"now", the monkey-patch automatically becomes dead code.
Since the code will, in theory, become totally dead eventually, it’d be nice to find out when it can be removed. The easiest way you can do that is to add an automated negative test against the functionality you’re adding, before any of your code runs — and thus before any possible monkey-patching.
describe "date 'now' patch" do # if this test fails, the monkey match on StandardFilters#date can be removed it "is necessary" do liquid_filter = Object.new liquid_filter.extend(Liquid::StandardFilters) liquid_filter.date("now", "%Y").should_not == Time.now.year.to_s end end
This is RSpec, but it doesn’t matter what the test looks like. Note that the test is explicitly asserting a negative. You could also add the patch in all cases, and then test against
Now, as soon as Liquid supports
"now", not only will your code become dead, but the test will fail and you’ll know it can be removed.
If a failing test causes problems, you can go for simply printing a warning.
Another example — Redcarpet
Here’s another example which fixes a regression bug in Redcarpet to use single quote characters. It’s better than the above because the work-around is less trivial.
renderer = Redcarpet::Markdown.new(Redcarpet::Render::SmartyHTML) renderer.render("foo's bar")
The issue is that the
SmartyHTML renderer incorrectly turns input like
"foo's bar" into
when it should instead use a curly quote from the Smartypants processing:
The problem is caused by
SmartyHTML not looking for HTML-escaped entities like
' as equivalent to
'. (Double quotes work fine.)
So there is a work-around, to substitute and reprocess.
def markdown(body) renderer = Redcarpet::Markdown.new(Redcarpet::Render::SmartyHTML) html = renderer.render(body).strip # make sure we aren't overriding unless we need to. # causes the workaround to automatically turn off. if !renderer.render("a 'quoted' word").include?("’") # fix the broken single curly quotes by putting them back # as unescaped characters and then re-running the renderer. html.gsub!("'", "'") html = renderer.render(html) end html end
As before, the work-around is dependent on a condition that checks if we even need it. If not, don’t bother.
In this case, the patch is much more iffy:
- There might be a
'that shouldn’t become a curly quote.
- Inside an element like
<code>, the text
foo's barcorrectly has a
', which will then be replaced by
'in the fix, and won’t be re-escaped into
'. This makes it somewhat less safe to use in HTML.
- We’re processing the entire input twice. That makes it slow.
Again, the corresponding check:
describe "curly quote patch" do # if this test fails, the workaround for the "markdown" filter can be removed it "is necessary" do renderer = Redcarpet::Markdown.new(Redcarpet::Render::SmartyHTML) renderer.render("something's here").should_not include("’s") end end
Since the patch is self-removing, these problems disappear once
SmartyHTML is fixed upstream and others update their version of Redcarpet. And eventually the test suite fails, letting you know it’s time to cut out the work-around.