Olag - Oren’s Library/Application Gem framework
Olag is Oren’s set of utilities for creating a well-behaved gem. This is very opinionated code; it eliminates a lot of the boilerplate, at the cost of making many decisions which may not be suitable for everyone (directory structure, code verification, Codnar for documentation, etc.).
A simple gem install olag should do the trick, assuming you have Ruby gems set up.
Olag's Rakefile is a good example of how to use Olag's classes to create a full-featured gem Rakefile:
$LOAD_PATH.unshift(File.dirname(__FILE__) + "/lib") require "olag/rake"
Gem specification
Olag::Rake.new(spec)
The overall Rakefile structure is as follows:
A first line sets up the Ruby module load path to begin with
the current gem's lib
directory. This standard idiom ensures we have access
to the current gem.
The next line imports Olag's rake
support module.
This is followed by setting up the gem specification, which is enhanced by Olag using monkey-patching.
Finally, Olag::Rake sets up the following tasks (as reported by rake -T
):
rake all # Version, verify, document, package
rake analyze # Analyze source code
rake changelog # Update ChangeLog from Git
rake clean # Remove any temporary products.
rake clean_codnar # Clean all split chunks
rake clobber # Remove any generated file.
rake clobber_codnar # Remove woven HTML documentation
rake clobber_coverage # Remove rcov products for coverage
rake clobber_package # Remove package products
rake clobber_rdoc # Remove rdoc products
rake codnar # Build the code narrative HTML
rake codnar_split # Split all files into chunks
rake codnar_weave # Weave chunks into HTML
rake commit # Git commit process
rake coverage # Test code covarage with RCov
rake doc # Generate all documentation
rake first_commit # Perform the 1st (main) Git commit
rake flay # Check for duplicated code with Flay
rake gem # Build the gem file olag-<version>.gem
rake package # Build all the packages
rake rdoc # Build the rdoc HTML Files
rake reek # Check for smelly code with Reek
rake repackage # Force a rebuild of the package files
rake rerdoc # Force a rebuild of the RDOC files
rake roodi # Check for smelly code with Roodi
rake saikuro # Check for complex code with Saikuro
rake second_commit # Perform the 2nd (amend) Git commit
rake test # Run tests for test
rake verify # Test, coverage, analyze code
rake version # Update version file from Git
The gem specification is provided as usual:
spec = Gem::Specification.new do |spec| spec.name = "olag" spec.version = Olag::VERSION spec.author = "Oren Ben-Kiki" spec.email = "rubygems-oren@ben-kiki.org" spec.homepage = "https://rubygems.org/gems/olag" spec.summary = "Olag - Oren's Library/Application Gem framework" spec.description = (<<-EOF).gsub(/^\s+/, "").chomp.gsub("\n", " ") Olag is Oren's set of utilities for creating a well-behaved gem. This is very opinionated software; it eliminates a lot of the boilerplate, at the cost of making many decisions which may not be suitable for everyone (directory structure, code verification, codnar for documentation, etc.). EOF end
However, the Gem::Specification class is monkey-patched to automatically several of the specification fields, and adding some new ones:
module Gem
|
Enhanced automated gem specification. |
class Specification
|
The title of the gem for documentation (by default, the capitalized name). |
attr_accessor :title
|
The name of the file containing the gem’s version (by default, lib/name/version.rb). |
attr_accessor :version_file alias_method :original_initialize, :initialize
|
Create the gem specification. Requires a block to set up the basic gem information (name, version, author, email, description). In addition, the block may override default properties (e.g. title). |
def initialize(&block) original_initialize(&block) setup_default_members add_development_dependencies setup_file_members setup_rdoc end
|
Set the new data members to their default values, unless they were already set by the gem specification block. |
def setup_default_members name = self.name @title ||= name.capitalize @version_file ||= "lib/#{name}/version.rb" end
|
Add dependencies required for developing the gem. |
def add_development_dependencies add_dependency("olag") unless self.name == "olag" %w(Saikuro codnar fakefs flay rake rcov rdoc reek roodi test-spec).each do |gem| add_development_dependency(gem) end end
|
Initialize the standard gem specification file list members. |
def setup_file_members
|
These should cover all the gem’s files, except for the extra rdoc files. |
setup_file_member(:files, "{lib,doc}/**/*") self.files << "Rakefile" << "codnar.html" setup_file_member(:executables, "bin/*") { |path| path.sub("bin/", "") } setup_file_member(:test_files, "test/**/*") end
|
Initialize a standard gem specification file list member to the files matching a pattern. If a block is given, it is used to map the file paths. This will append to the file list member if it has already been set by the gem specification block. |
def setup_file_member(member, pattern, &block) old_value = instance_variable_get("@#{member}") new_value = FileList[pattern].find_all { |path| File.file?(path) } new_value.map!(&block) if block instance_variable_set("@#{member}", old_value + new_value) end
|
Setup RDOC options in the gem specification. |
def setup_rdoc self.extra_rdoc_files = [ "README.rdoc", "LICENSE", "ChangeLog" ] self.rdoc_options << "--title" << "#{title} #{version}" \ << "--main" << "README.rdoc" \ << "--line-numbers" \ << "--all" \ << "--quiet" end end end
The Olag::Rake class sets up the tasks listed above as follows:
require "codnar/rake" require "olag/change_log" require "olag/gem_specification" require "olag/update_version" require "olag/version" require "rake/clean" require "rake/testtask" require "rcov/rcovtask" require "rdoc/task" require "reek/rake/task" require "roodi" require "rubygems/package_task" module Olag
|
Automate Rake task creation for a gem. |
class Rake include ::Rake::DSL
|
Define all the Rake tasks. |
def initialize(spec) @spec = spec @ruby_sources = @spec.files.find_all { |file| file =~ /^Rakefile$|\.rb$/ } @weave_configurations = [ :weave_include, :weave_named_chunk_with_containers, :weave_plain_chunk ] task(:default => :all) define_all_task CLOBBER << "saikuro" end protected
|
Define a task that does “everything”. |
def define_all_task define_desc_task("Version, verify, document, package", :all => [ :version, :verify, :doc, :package ]) define_verify_task define_doc_task define_commit_task
|
This is a problem. If the version number gets updated, Gem::PackageTask
fails. This is better than if it used the old version number, I suppose,
but not as nice as if it just used @spec.version everywhere. The solution
for this is to do a dry run before doing the final |
Gem::PackageTask.new(@spec) { |package| } end
Verify gem functionality
Analyze the source code
Generate RDoc documentation
Generate Codnar documentation
Automate Git commit process
Task utilities
end end
The following utilities are used to create the different tasks. It would have be nicer if Rake had treated the task description as just another task property.
Define a new task with a description. |
def define_desc_task(description, *parameters) desc(description) task(*parameters) do yield(task) if block_given? end end
Define a new task using some class. |
def define_desc_class(description, klass, *parameters) desc(description) klass.new(*parameters) do |task| yield(task) if block_given? end end
The following tasks verify that the gem is correct. Testing for 100% code coverage seems excessive but in reality it isn't that hard to do, and is really only a modest form of test coverage verification.
Define a task to verify everything is OK. |
def define_verify_task define_desc_task("Test, coverage, analyze code", :verify => [ :coverage, :analyze ]) define_desc_class("Test code covarage with RCov", Rcov::RcovTask, "coverage") { |task| Rake.configure_coverage_task(task) } define_desc_class("Test code without coverage", ::Rake::TestTask, "test") { |task| Rake.configure_test_task(task) } define_analyze_task end
Configure a task to run all tests and verify 100% coverage. This is cheating a bit, since it will not complain about files that are not reached at all from any of the tests. |
def self.configure_coverage_task(task) task.test_files = FileList["test/*.rb"] task.libs << "lib" << "test/lib" task.rcov_opts << "--failure-threshold" << "100" \ << "--exclude" << "^/"
|
Strangely, on some occasions, RCov crazily tries to compute coverage for files inside Ruby’s gem repository. Excluding all absolute paths prevents this. |
end
Configure a task to just run the tests without verifying coverage. |
def self.configure_test_task(task) task.test_files = FileList["test/*.rb"] task.libs << "lib" << "test/lib" end
The following tasks verify that the code is squeacky-clean. While passing the
code through all these verifiers seems excessive, it isn't that hard to achieve
in practice. There were several times I did refactorings "just to satisfy
reek
(or flay
)" and ended up with an unexpected code improvement. Anyway,
if you aren't a youch OCD about this sort of thing, Olag is probably not for
you :-)
Define a task to verify the source code is clean. |
def define_analyze_task define_desc_task("Analyze source code", :analyze => [ :reek, :roodi, :flay, :saikuro ]) define_desc_class("Check for smelly code with Reek", Reek::Rake::Task) { |task| configure_reek_task(task) } define_desc_task("Check for smelly code with Roodi", :roodi) { run_roodi_task } define_desc_task("Check for duplicated code with Flay", :flay) { run_flay_task } define_desc_task("Check for complex code with Saikuro", :saikuro) { run_saikuro_task } end
Configure a task to ensure there are no code smells using Reek. |
def configure_reek_task(task) task.reek_opts << "--quiet" task.source_files = @ruby_sources end
Run Roodi to ensure there are no code smells. |
def run_roodi_task runner = Roodi::Core::Runner.new runner.config = "roodi.config" if File.exist?("roodi.config") @ruby_sources.each { |ruby_source| runner.check_file(ruby_source) } (errors = runner.errors).each { |error| puts(error) } raise "Roodi found #{errors.size} errors." unless errors.empty? end
Run Flay to ensure there are no duplicated code fragments. |
def run_flay_task dirs = %w(bin lib test/lib).find_all { |dir| File.exist?(dir) } result = IO.popen("flay " + dirs.join(' '), "r").read.chomp return if result == "Total score (lower is better) = 0\n" puts(result) raise "Flay found code duplication." end
Run Saikuro to ensure there are no overly complex functions. |
def run_saikuro_task dirs = %w(bin lib test).find_all { |dir| File.exist?(dir) } system("saikuro -c -t -y 0 -e 10 -o saikuro/ -i #{dirs.join(' -i ')} > /dev/null") result = File.read("saikuro/index_cyclo.html") raise "Saikuro found complicated code." if result.include?("Errors and Warnings") end
The following tasks generate the usual RDoc documentation, required to make the gem behave well in the Ruby tool ecosystem:
Define a task to build all the documentation. |
def define_doc_task desc "Generate all documentation" task :doc => [ :rdoc, :codnar ] ::Rake::RDocTask.new { |task| configure_rdoc_task(task) } define_codnar_task end
Configure a task to build the RDoc documentation. |
def configure_rdoc_task(task) task.rdoc_files += @ruby_sources.reject { |file| file =~ /^test|Rakefile/ } + [ "LICENSE", "README.rdoc" ] task.main = "README.rdoc" task.rdoc_dir = "rdoc" task.options = @spec.rdoc_options end
The following tasks generate the Codnar documentation (e.g., the document you are reading now), which goes beyond RDoc to provide an end-to-end linear narrative describing the gem:
A set of file Regexp patterns and their matching Codnar configurations. All the gem files are matched agains these patterns, in order, and a Codnar::SplitTask is created for the first matching one. If the matching configuration list is empty, the file is not split. However, the file must match at least one of the patterns. The gem is expected to modify this array, if needed, before creating the Rake object. |
CODNAR_CONFIGURATIONS = [
[
|
Exclude the ChangeLog and generated codnar.html files from the generated documentation. |
"ChangeLog|codnar\.html", ], [
|
Configurations for splitting Ruby files. Using Sunlight makes for fast splitting but slow viewing. Using GVim is the reverse. |
"Rakefile|.*\.rb|bin/.*", "classify_source_code:ruby", "format_code_gvim_css:ruby", "classify_shell_comments", "format_rdoc_comments", "chunk_by_vim_regions", ], [
|
Configurations for splitting Haskell files. Not that ruby gems tend to contain Haskell files, but it allows more easily adding a Rakefile for generating codnar documentation into a Haskell project. |
".*\.hs", "classify_source_code:haskell", "format_code_gvim_css:haskell", "classify_haddock_comments", "format_haddock_comments", "chunk_by_vim_regions", ], [
|
Configurations for GraphViz diagram files. |
".*\.dot", "split_graphviz_documentation", ], [
|
Configurations for HTML documentation files. |
".*\.html", "split_html_documentation", ], [
|
Configurations for Markdown documentation files. |
".*\.markdown|.*\.md", "split_markdown_documentation", ], [
|
Configurations for RDOC documentation files. |
"LICENSE|.*\.rdoc", "split_rdoc_documentation", ], ]
Define a task to build the Codnar documentation. |
def define_codnar_task @spec.files.each do |file| configurations = Rake.split_configurations(file) Codnar::Rake::SplitTask.new([ file ], configurations) unless configurations == [] end Codnar::Rake::WeaveTask.new("doc/root.html", @weave_configurations) end
Find the Codnar split configurations for a file. |
def self.split_configurations(file) CODNAR_CONFIGURATIONS.each do |configurations| regexp = configurations[0] = convert_to_regexp(configurations[0]) return configurations[1..-1] if regexp.match(file) end abort("No Codnar configuration for file: #{file}") end
Convert a string configuration pattern to a real Regexp. |
def self.convert_to_regexp(regexp) return regexp if Regexp == regexp begin return Regexp.new("^(#{regexp})$") rescue abort("Invalid pattern regexp: ^(#{regexp})$ error: #{$!}") end end
Codnar is very configurable, and the above provides a reasonable default configuration for pure Ruby gems. You can modify the CODNAR_CONFIGURATIONS array before creating the Rake object, by unshifting additional/overriding patterns into it. For example, you may choose to use GVim for syntax highlighting. This will cause splitting to become much slower, but the generated HTML will already include the highlighting markup so it will display instantly. Or, you may have additional source file types (Javascript, CSS, HTML, C, etc.) to be highlighted.
In an ideal world, committing to Git would be a simple matter of typing git
commit -m "..."
. In our case, things get a bit complicated.
There is some information that we need to extract out of Git and inject into
our files (to be committed). Since Git pre-commit hooks do not allow us to
modify any source files, this turns commit into a two-phase process: we do an
initial commit, update some files, then git commit --amend
to merge them with
the first commit.
Define a task that commit changes to Git. |
def define_commit_task define_desc_task("Git commit process", :commit => [ :all, :first_commit, :changelog, :second_commit ]) define_desc_task("Update version file from Git", :version) { update_version_file } define_desc_task("Perform the 1st (main) Git commit", :first_commit) { run_git_first_commit } define_desc_task("Perform the 2nd (amend) Git commit", :second_commit) { run_git_second_commit } define_desc_task("Update ChangeLog from Git", :changelog) { Olag::ChangeLog.new("ChangeLog") } end
Update the content of the version file to contain the correct Git-derived build number. |
def update_version_file version_file = @spec.version_file updated_version = Olag::Version::update(version_file) abort("Updated gem version; re-run rake") if @spec.version.to_s != updated_version end
Run the first Git commit. The user will be given an editor to review the commit and enter a commit message. |
def run_git_first_commit raise "Git 1st commit failed" unless system("set +x; git commit") end
Run the second Git commit. This amends the first commit with the updated ChangeLog. |
def run_git_second_commit raise "Git 2nd commit failed" unless system("set +x; EDITOR=true git commit --amend ChangeLog") end
The first piece of information we need to extract from Git is the current build number, which needs to be injected into the gem's version number:
This module contains all the Olag code. |
module Olag
|
This version number. The third number is automatically updated to track the
number of Git commits by running |
VERSION = "0.1.22" end
Documentation generation will depend on the content (and therefore modification time) of this file. Luckily, we can update this number before the first commit, and we can ensure it only touches the file if there is a real change, to avoid unnecessary documentation regeneration:
module Olag module Version
|
Update the file containing the gem’s version. The file is expected to contain a line in the format: VERSION = "major.minor.commits". The third number is updated according to the number of Git commits. This works well as long as we are working in the master branch. |
def self.update(path) current_file_contents, current_version, correct_version = current_status(path) if current_version != correct_version correct_file_contents = current_file_contents.sub(current_version, correct_version) File.open(path, "w") { |file| file.write(correct_file_contents) } end return correct_version end protected
|
Return the current version file contents, the current version, and the correct version. |
def self.current_status(path) prefix, current_suffix = extract_version(path, current_file_contents = File.read(path)) correct_suffix = count_git_commits.to_s current_version = prefix + current_suffix correct_version = prefix + correct_suffix return current_file_contents, current_version, correct_version end
|
Extract the version number from the contents of the version file. This is an array of two strings - the prefix containing the major and minor numbers, and the suffix containing the commits number. |
def self.extract_version(path, file_contents) abort("#{path}: Does not contain a valid VERSION line.") unless file_contents =~ /VERSION\s+=\s+["'](\d+\.\d+\.)(\d+)["']/ return [ $1, $2 ] end
|
Return the total number of Git commits that apply to the current state of the working directory. This means we add one to the actual number of commits if there are uncommitted changes; this way the version number does not change after doing a commit - it only changes after we make changes following a commit. |
def self.count_git_commits git_commits = IO.popen("git rev-list --all | wc -l").read.chomp.to_i git_status = IO.popen("git status").read git_commits += 1 unless git_status.include?("working directory clean") return git_commits end end end
The second information we extract from Git is the ChangeLog file. Here, obviously, the ChangeLog needs to include the first commit's message, so we are forced to regenerate the file and amend Git's history with a second commit:
module Olag
|
Create ChangeLog files based on the Git revision history. |
class ChangeLog
|
Write a changelog based on the Git log. |
def initialize(path) @subjects_by_id = {} @sorted_ids = [] read_log_lines File.open(path, "w") do |file| @log_file = file write_log_file end end protected
|
Read all the log lines from Git’s revision history. |
def read_log_lines IO.popen("git log --pretty='format:%ci::%an <%ae>::%s'", "r").each_line do |log_line| load_log_line(log_line) end end
|
Load a single Git log line into memory. |
def load_log_line(log_line) id, subject = ChangeLog.parse_log_line(log_line) @sorted_ids << id @subjects_by_id[id] ||= [] @subjects_by_id[id] << subject end
|
Extract the information we need (ChangeLog entry id and subject) from a Git log line. |
def self.parse_log_line(log_line) date, author, subject = log_line.chomp.split("::") date, time, zone = date.split(" ") id = "#{date}\t#{author}" return id, subject end
|
Write a ChangeLog file based on the read Git log lines. |
def write_log_file @sorted_ids.uniq.each do |id| write_log_entry(id, @subjects_by_id[id]) end end
|
Write a single ChaneLog entry. |
def write_log_entry(id, subjects) @log_file.puts "#{id}\n\n" @log_file.puts subjects.map { |subject| "\t* #{subject}" }.join("\n") @log_file.puts "\n" end end end
Olag provides a set of utility classes that are useful in implementing well-behaved gems.
When using "here documents" (<<EOF
data), it is nice to be able to indent the
data to match the surrounding code. There are other cases where it is useful to
"unindent" multi-line text. The following tests demonstrates using the
unindent
function:
require "olag/string_unindent" require "test/spec"
Test unindenting a multi-line text. |
class TestUnindentText < ::Test::Unit::TestCase def test_automatic_unindent <<-EOF.unindent.should == "a\n b\n" a b EOF end def test_invalid_unindent " a\n b\n".unindent.should == "a\n b\n" end def test_integer_unindent " a\n b\n".unindent(1).should == " a\n b\n" end def test_string_unindent " a\n b\n".unindent(" ").should == " a\n b\n" end end
And here is the implementation extending the built-in String class:
Extend the core String class. |
class String
|
Strip away common indentation from the beginning of each line in this String. By default, detects the indentation from the first line. This can be overriden to the exact (String) indentation to strip, or to the (Fixnum) number of spaces the first line is further-indented from the rest of the text. |
def unindent(unindentation = 0) unindentation = " " * (indentation.length - unindentation) if Fixnum === unindentation return gsub(/^#{unindentation}/, "") end
|
Extract the indentation from the beginning of this String. |
def indentation return sub(/[^ ].*$/m, "") end end
Sometimes it is useful to package some files inside a gem, to be read by user
code. This is of course trivial for Ruby code files (just use require
) but
not trivial if you want, say, to include some CSS files in your gem for
everyone to use. Olag provides a way to resolve the path of any file in any gem
(basically replicating what require
does). Here is a simple test of using
this functionality:
require "olag/data_files" require "test/spec"
Test accessing data files packages with the gem. |
class TestAccessDataFiles < Test::Unit::TestCase def test_access_data_file File.exist?(Olag::DataFiles.expand_path("olag/data_files.rb")).should == true end def test_access_missing_file Olag::DataFiles.expand_path("no-such-file").should == "no-such-file" end end
And here is the implementation:
module Olag
|
Provide access to data files packaged with the gem. |
module DataFiles
|
Given the name of a data file packaged in some gem, return the absolute disk path for accessing that data file. This is similar to what require does internally, but here we just want the path for reading data, rather than load the Ruby code in the file. |
def self.expand_path(relative_path) $LOAD_PATH.each do |load_directory| absolute_path = File.expand_path(load_directory + "/" + relative_path) return absolute_path if File.exist?(absolute_path) end return relative_path # This will cause "file not found error" down the line. end end end
Javascript has an interesting convention where hash["key"]
and hash.key
mean the same thing. This is very useful in cutting down boilerplate code, and
it also makes your data serialize to very clean YAML. Unlike OpenStruct, you do
not need to define all the members in advance, and you can alternate between
the .key
and ["key"]
forms as convenient for any particular piece of code.
The down side is that you lose any semblance of type checking - misspelled
member names and other errors are silently ignored. Well, that's what we have
unit tests for, right? :-)
Olag provides an extension to the Hash class that provides the above, for these who have chosen to follow the dark side of the force. Here is a simple test demonstrating using this ability:
require "olag/hash_struct" require "test/spec"
Test accessing missing keys as members. |
class TestMissingKeys < ::Test::Unit::TestCase def test_read_missing_key {}.missing.should == nil end def test_set_missing_key hash = {} hash.missing = "value" hash.missing.should == "value" end end
And here is the implementation:
Extend the core Hash class. |
class Hash
|
Provide OpenStruct/JavaScript-like implicit .key and .key= methods. |
def method_missing(method, *arguments) method = method.to_s key = method.chomp("=") return method == key ? self[key] : self[key] = arguments[0] end end
In order to get deterministic test results, and for general hukman-friendlyness, it is often desirable to sort hash table keys when dumping it to YAML. In Ruby 1.8.x, this isn't the default. It is possible to introduce this behavior using the following hack:
require "yaml" if RUBY_VERSION.include?("1.8")
|
Modify the hash class to emit YAML sorted keys. This is an ugly hack, specifically for Ruby 1.8.*. |
class Hash
|
Provide access to the old, unsorted implementation. |
alias :unsorted_to_yaml :to_yaml
|
Return the hash in YAML format with sorted keys. |
def to_yaml(opts = {}) YAML::quick_emit(self, opts) do |out| out.map(taguri, to_yaml_style) do |map| to_yaml_sorted_keys.each do |key| map.add(key, fetch(key)) end end end end
|
Return the hash keys, sorted for emitting into YAML. |
def to_yaml_sorted_keys begin return keys.sort rescue return to_yaml_lexicographically_sorted_keys end end
|
Return the hash keys, sorted lexicographically for emitting into YAML. |
def to_yaml_lexicographically_sorted_keys begin return keys.sort_by {|key| key.to_s} rescue return keys end end end end
Here is a simple test demonstrating using it:
require "olag/hash_sorted_yaml" require "test/spec"
An uncomparable class. Keys of this class will cause the Hash to be emitted in string sort order. |
class Uncomparable def initialize(value) @value = value end def to_yaml(opts = {}) return @value.to_yaml(opts) end def to_s return @value.to_s end %w(<=> < <= >= >).each do |operator| define_method(operator) { raise "Prevent operator: #{operator}" } end end
An uncomparable class that can’t be converted to a string. Keys of this class will cause the Hash to be emitted in unsorted order. |
class Unsortable < Uncomparable def to_s raise "Prevent conversion to string as well." end end
Test sorting keys of YAML generated from Hash tables. |
class TestSortedKeys < ::Test::Unit::TestCase SORTED_NUMERICALLY = <<-EOF.unindent --- 2: 2 4: 4 11: 1 33: 3 EOF SORTED_LEXICOGRAPHICALLY = <<-EOF.unindent --- 11: 1 2: 2 33: 3 4: 4 EOF def test_sortable_keys { 2 => 2, 4 => 4, 11 => 1, 33 => 3 }.to_yaml.gsub(/ +$/, "").should == SORTED_NUMERICALLY end def test_uncomparable_keys { Uncomparable.new(11) => 1, 2 => 2, 33 => 3, 4 => 4 }.to_yaml.gsub(/ +$/, "").should == SORTED_LEXICOGRAPHICALLY end def test_unsortable_keys yaml_should_not = { Unsortable.new(11) => 1, 2 => 2, 33 => 3, 4 => 4 }.to_yaml.gsub(/ +$/, "").should.not yaml_should_not == SORTED_NUMERICALLY yaml_should_not == SORTED_LEXICOGRAPHICALLY end end
In library code, it is bad practice to terminate the program on an error. Raising an exception is preferrable, but that forces you to abort the processing. In some cases, it is preferrable to collect the error, skip a bit of processing, and continue (if only for detecting additional errors). For example, one would expect a compiler to emit more than just the first syntax error message.
Olag provides an error collection class that also automatically formats the error to indicate its location. Here is a simple test that demonstrates collecting errors:
require "olag/errors" require "olag/test" require "test/spec"
Test collecting errors. |
class TestCollectErrors < Test::Unit::TestCase include Test::WithErrors include Test::WithFakeFS def test_one_error @errors << "Oops" @errors.should == [ "#{$0}: Oops" ] end def test_path_error @errors.in_path("foo") do @errors << "Oops" "result" end.should == "result" @errors.should == [ "#{$0}: Oops in file: foo" ] end def test_line_error @errors.in_path("foo") do @errors.at_line(1) @errors << "Oops" end @errors.should == [ "#{$0}: Oops in file: foo at line: 1" ] end def test_file_error write_fake_file("foo", "bar\n") @errors.in_file("foo") do @errors << "Oops" "result" end.should == "result" @errors.should == [ "#{$0}: Oops in file: foo" ] end def test_file_lines_error write_fake_file("foo", "bar\nbaz\n") @errors.in_file_lines("foo") do |line| @errors << "Oops" if line == "baz\n" end @errors.should == [ "#{$0}: Oops in file: foo at line: 2" ] end end
Which uses a mix-in that helps writing tests that use errors:
require "olag/errors" module Test
|
Mix-in for tests that collect Errors. |
module WithErrors
|
Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class. |
def self.included(base) base.class_eval do alias_method :errors_original_setup, :setup
|
Automatically create an fresh +@errors+ data member for each test. |
def setup errors_original_setup @errors = Olag::Errors.new end end end end end
Here is the actual implementation:
module Olag
|
Collect a list of errors. |
class Errors < Array
|
The current path we are reporting errors for, if any. |
attr_reader :path
|
The current line number we are reporting errors for, if any. |
attr_reader :line_number
|
Create an empty errors collection. |
def initialize @path = nil @line_number = nil end
|
Associate all errors collected by a block with a specific disk file. |
def in_path(path, &block) prev_path, prev_line_number = @path, @line_number @path, @line_number = path, nil result = block.call(path) @path, @line_number = prev_path, prev_line_number return result end
|
Associate all errors collected by a block with a disk file that is opened and passed to the block. |
def in_file(path, mode = "r", &block) return in_path(path) { File.open(path, mode, &block) } end
|
Associate all errors collected by a block with a line read from a disk file that is opened and passed to the block. |
def in_file_lines(path, &block) in_file(path) do |file| @line_number = 0 file.each_line do |line| @line_number += 1 block.call(line) end end end
|
Set the line number for any errors collected from here on. |
def at_line(line_number) @line_number = line_number end
|
Add a single error to the collection, with automatic context annotation (current disk file and line). Other methods (push, += etc.) do not automatically add the context annotation. |
def <<(message) push(annotate_error_message(message)) end protected
|
Annotate an error message with the context (current file and line). |
def annotate_error_message(message) return "#{$0}: #{message}" unless @path return "#{$0}: #{message} in file: #{@path}" unless @line_number return "#{$0}: #{message} in file: #{@path} at line: #{@line_number}" end end end
Sometimes tests need to muck around with disk files. One way to go about it is to create a temporary disk directory, work in there, and clean it up when done. Another, simpler way is to use the FakeFS file system, which captures all(most) of Ruby's file operations and redirect them to an in-memory fake file system. Here is a mix-in that helps writing tests using FakeFS (we will use it below when running applications inside unit tests):
require "fileutils" require "fakefs/safe" module Test
|
Mix-in for tests that use the FakeFS fake file system. |
module WithFakeFS
|
Create and write into a file on the fake file system. |
def write_fake_file(path, content = nil, &block) directory = File.dirname(path) FileUtils.mkdir_p(directory) unless File.exists?(directory) File.open(path, "w") do |file| file.write(content) unless content.nil? block.call(file) unless block.nil? end end
|
Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class. |
def self.included(base) base.class_eval do alias_method :fakefs_original_setup, :setup
|
Automatically create an fresh fake file system for each test. |
def setup fakefs_original_setup FakeFS.activate! FakeFS::FileSystem.clear end alias_method :fakefs_original_teardown, :teardown
|
Automatically clean up the fake file system at the end of each test. |
def teardown fakefs_original_teardown FakeFS.deactivate! end end end end end
When running external programs, actually generating a temporary disk file is sometimes inevitable. Of course, such files need to be cleaned up when the test is done. If we need more than just one such file, it is easier to create a whole temporary directory which is easily cleaned up in one operation.
Here is a mix-in that helps writing tests using temporary files and folders:
require "fileutils" require "tempfile" module Test
|
Mix-in for tests that write a temporary disk file. |
module WithTempfile
|
Create a temporary file on the disk. The file will be automatically removed when the test is done. |
def write_tempfile(path, content, directory = ".") file = Tempfile.open(path, directory) file.write(content) file.close(false) (@tempfiles ||= []) << file return file.path end
|
Create a temporary directory on the disk. The directory will be automatically removed when the test is done. This is very useful for complex file tests that can’t use FakeFS. |
def create_tempdir(directory = ".") file = Tempfile.open("dir", directory) (@tempfiles ||= []) << file File.delete(path = file.path) Dir.mkdir(path) return path end
|
Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class. |
def self.included(base) base.class_eval do alias_method :tempfile_original_teardown, :teardown
|
Automatically clean up the temporary files when the test is done. |
def teardown tempfile_original_teardown (@tempfiles || []).each do |tempfile| path = tempfile.path FileUtils.rm_rf(path) if File.exist?(path) end end end end end end
When running external programs, or sometimes even Ruby programs, it is useful
to have a temporary directory to stuff everything into, which is automatically
removed when the test is done. The WithTempfile
module does provide the
create_tempdir
function, which we can use; but it is easier to also include
the WithTempdir
module, which does this automatically in the setup
function
for us, and precomputes up the standard I/O file names as well.
require "olag/test/with_tempfile" module Test
|
Mix-in for tests that run applications in a temporary directory. This assumes that the test class has already mixed-in the WithTempfile mix-in. |
module WithTempdir
|
Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class. |
def self.included(base) base.class_eval do alias_method :tempdir_original_setup, :setup
|
Create a temporary directory for the run and percompute the standard I/O file names in it. |
def setup tempdir_original_setup @tempdir = create_tempdir @stdout = @tempdir + "/stdout" @stdin = @tempdir + "/stdin" @stderr = @tempdir + "/stderr" end end end end end
Testing Rake tasks is tricky because tests may be run in the context of Rake. Therefore, the best practice is to create a new Rake application and restore the original when the test is done:
module Test
|
Mix-in for tests that use Rake. |
module WithRake
|
Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class. |
def self.included(base) base.class_eval do alias_method :rake_original_setup, :setup
|
Automatically create a fresh Rake application. |
def setup rake_original_setup @original_rake = Rake.application @rake = Rake::Application.new Rake.application = @rake end alias_method :rake_original_teardown, :teardown
|
Automatically restore the original Rake application. |
def teardown rake_original_teardown Rake.application = @original_rake end end end end end
Rather than requiring each of the above test mix-in modules on its own, it is
convenient to just require "olag/test"
and be done:
require "olag/test/with_errors" require "olag/test/with_fakefs" require "olag/test/with_rake" require "olag/test/with_tempfile"
Enhance the global test module with additional mix-in modules. |
module Test end
Writing an application requires a lot of boilerplate. Olag provides an Application base class that handles standard command line flags, execution from within tests, and errors collection.
Here is a simple test for running such an application from unit tests:
require "olag/application" require "olag/test" require "test/spec"
An application that emits an error when run. |
class ErrorApplication < Olag::Application
|
Run the error application. |
def run super { @errors << "Oops!" } end
|
Test minimal number of arguments. |
def parse_arguments expect_at_least(2, "fake arguments") expect_at_most(3, "fake arguments") end end
Test running a Olag Application. |
class TestRunApplication < Test::Unit::TestCase include Test::WithFakeFS def test_do_nothing Olag::Application.with_argv([]) { Olag::Application.new(true).run }.should == 0 end def test_inexact_arguments Olag::Application.with_argv(%w(-e stderr foo)) { Olag::Application.new(true).run }.should == 1 File.read("stderr").should.include?("Expects no arguments") end def test_missing_arguments Olag::Application.with_argv(%w(-e stderr foo)) { ErrorApplication.new(true).run }.should == 1 File.read("stderr").should.include?("Expects at least 2 fake arguments") end def test_extra_arguments Olag::Application.with_argv(%w(-e stderr foo bar baz bad)) { ErrorApplication.new(true).run }.should == 1 File.read("stderr").should.include?("Expects at most 3 fake arguments") end def test_print_version Olag::Application.with_argv(%w(-o nested/stdout -v -h)) { Olag::Application.new(true).run }.should == 0 File.read("nested/stdout").should == "#{$0}: Version: #{Olag::VERSION}\n" end def test_print_help Olag::Application.with_argv(%w(-o stdout -h -v)) { Olag::Application.new(true).run }.should == 0 File.read("stdout").should.include?("DESCRIPTION:") end def test_print_errors Olag::Application.with_argv(%w(-e stderr foo bar)) { ErrorApplication.new(true).run }.should == 1 File.read("stderr").should.include?("Oops!") end end
And here is the implementation:
require "fileutils" require "olag/errors" require "olag/globals" require "olag/string_unindent.rb" require "olag/version" require "optparse" module Olag
|
Base class for Olag applications. |
class Application
|
Create a Olag application. |
def initialize(is_test = nil) @errors = Errors.new @is_test = !!is_test end
|
Run the Olag application, returning its status. |
def run(*arguments, &block) parse_options yield(*arguments) if block_given? return print_errors rescue ExitException => exception return exception.status end
|
Execute a block with an overriden ARGV, typically for running an application. |
def self.with_argv(argv) return Globals.without_changes do ARGV.replace(argv) yield end end protected
|
Parse the command line options of the program. |
def parse_options parser = OptionParser.new do |options| (@options = options).banner = banner + "\n\nOPTIONS:\n\n" define_flags end parser.parse! parse_arguments end
|
Expect a limited number of remaining arguments (verified by the block). |
def expect_limited_arguments(message_prefix, arguments_limit, argument_type, &block) if !yield(ARGV.size) $stderr.puts("#{$0}: #{message_prefix} #{arguments_limit} #{argument_type}.") exit(1) end end
|
Ensure we have got at least a certain number of command line arguments. |
def expect_at_least(minimal_arguments, argument_type) expect_limited_arguments("Expects at least", minimal_arguments, argument_type) { |remaining_arguments| remaining_arguments >= minimal_arguments } end
|
Ensure we have got at least a certain number of command line arguments. |
def expect_at_most(maximal_arguments, argument_type) expect_limited_arguments("Expects at most", maximal_arguments, argument_type) { |remaining_arguments| remaining_arguments <= maximal_arguments } end
|
Ensure we have got an exact number of command line arguments. |
def expect_exactly(exact_arguments, argument_type) arguments_limit = exact_arguments == 0 ? 'no' : exact_arguments expect_limited_arguments("Expects", arguments_limit, argument_type) { |remaining_arguments| remaining_arguments == exact_arguments } end
|
Parse remaining command-line file arguments. This is expected to be overriden by the concrete application sub-class. By default assumes there are no such arguments. |
def parse_arguments expect_exactly(0, "arguments") end
|
Define application flags. This is expected to be overriden by the concrete application sub-class. |
def define_flags define_help_flag define_version_flag define_redirect_flag("$stdout", "output", "w") define_redirect_flag("$stderr", "error", "w") #! Most scripts do not use this, but they can add it. #! define_redirect_flag("$stdin", "input", "r") end
|
Define the standard help flag. |
def define_help_flag @options.on("-h", "--help", "Print this help message and exit.") do puts(@options) print_additional_help exit(0) end end
|
Print additional help message. This includes both the command line file arguments, if any, and a short description of the program. |
def print_additional_help arguments_name, arguments_description = arguments puts(format(" %-33s%s", arguments_name, arguments_description)) if arguments_name print("\nDESCRIPTION:\n\n") print(description) end
|
Return the banner line of the help message. This is expected to be overriden by the concrete application sub-class. By default returns the path name of thje executed program. |
def banner return $0 end
|
Return the name and description of any final command-line file arguments, if any. This is expected to be overriden by the concrete application sub-class. By default, assume there are no final command-line file arguments (however, `parse_options` does not enforce this by default). |
def arguments return nil, nil end
|
Return a short description of the program. This is expected to be overriden by the concrete application sub-class. By default, provide |
def description return "Sample description\n" end
|
Define the standard version flag. |
def define_version_flag version_number = version @options.on("-v", "--version", "Print the version number (#{version_number}) and exit.") do puts("#{$0}: Version: #{version_number}") exit(0) end end
|
Define a flag redirecting one of the standard IO files. |
def define_redirect_flag(variable, name, mode) @options.on("-#{name[0,1]}", "--#{name} FILE", String, "Redirect standard #{name} to a file.") do |file| eval("#{variable} = Application::redirect_file(#{variable}, file, mode)") end end
|
Redirect a standard file. |
def self.redirect_file(default, file, mode) return default if file.nil? || file == "-" FileUtils.mkdir_p(File.dirname(File.expand_path(file))) if mode == "w" return File.open(file, mode) end
|
Return the application’s version. This is expected to be overriden by the concrete application sub-class. In the base class, we just return Olag’s version which only useful for Olag’s tests. |
def version return Olag::VERSION end
|
Print all the collected errors. |
def print_errors @errors.each do |error| $stderr.puts(error) end return @errors.size end
|
Exit the application, unless we are running inside a test. |
def exit(status) Kernel.exit(status) unless @is_test raise ExitException.new(status) end end
|
Exception used to exit when running inside tests. |
class ExitException < Exception
|
The exit status. |
attr_reader :status
|
Create a new exception to indicate exiting the program with some status. |
def initialize(status) @status = status end end end
It makes use of the following utility class, for saving and restoring the global state when running an application in a test:
module Olag
|
Save and restore the global variables when running an application inside a test. |
class Globals
|
Run some code without affecting the global state. |
def self.without_changes(&block) state = Globals.new begin return block.call ensure state.restore end end
|
Restore the relevant global variables. |
def restore $stdin = Globals.restore_file($stdin, @original_stdin) $stdout = Globals.restore_file($stdout, @original_stdout) $stderr = Globals.restore_file($stderr, @original_stderr) ARGV.replace(@original_argv) end protected
|
Take a snapshot of the relevant global variables. |
def initialize @original_stdin = $stdin @original_stdout = $stdout @original_stderr = $stderr @original_argv = ARGV.dup end
|
Restore a specific global file variable to its original state. |
def self.restore_file(current, original) current.close unless current == original return original end end end
Olag is published under the MIT license:
Copyright © 2010-2011 Oren Ben-Kiki
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Monkey-patch within the Gem module.