diff options
| author | Pieter van de Bruggen <pieter@puppetlabs.com> | 2011-07-19 10:55:26 -0700 |
|---|---|---|
| committer | Pieter van de Bruggen <pieter@puppetlabs.com> | 2011-07-19 10:55:58 -0700 |
| commit | 72abe6ce7192bba0b295a8a83483668d21050624 (patch) | |
| tree | 4105862eff83984df4e1a05cc073aa4412329b87 | |
| parent | 756314da4e48b8a138a1e38f1ae276d6721ef410 (diff) | |
| download | puppet-72abe6ce7192bba0b295a8a83483668d21050624.tar.gz puppet-72abe6ce7192bba0b295a8a83483668d21050624.tar.xz puppet-72abe6ce7192bba0b295a8a83483668d21050624.zip | |
(#7204) Consolidate Semantic Versioning code.
This introduces a class representing a semantic version, and
implementing a few of the most common uses of them: validation,
comparison, and finding the greatest available version matching
a range. This refactoring also allows us to easily expand our
matching of version ranges in the future, which is a big plus.
Reviewed-By: Daniel Pittman
| -rw-r--r-- | lib/puppet/interface.rb | 5 | ||||
| -rw-r--r-- | lib/puppet/interface/face_collection.rb | 49 | ||||
| -rw-r--r-- | lib/semver.rb | 65 | ||||
| -rwxr-xr-x | spec/unit/interface/face_collection_spec.rb | 40 | ||||
| -rw-r--r-- | spec/unit/semver_spec.rb | 187 |
5 files changed, 266 insertions, 80 deletions
diff --git a/lib/puppet/interface.rb b/lib/puppet/interface.rb index 6be8b6930..6c288f3c0 100644 --- a/lib/puppet/interface.rb +++ b/lib/puppet/interface.rb @@ -2,6 +2,7 @@ require 'puppet' require 'puppet/util/autoload' require 'puppet/interface/documentation' require 'prettyprint' +require 'semver' class Puppet::Interface include FullDocs @@ -84,12 +85,12 @@ class Puppet::Interface attr_reader :name, :version def initialize(name, version, &block) - unless Puppet::Interface::FaceCollection.validate_version(version) + unless SemVer.valid?(version) raise ArgumentError, "Cannot create face #{name.inspect} with invalid version number '#{version}'!" end @name = Puppet::Interface::FaceCollection.underscorize(name) - @version = version + @version = SemVer.new(version) # The few bits of documentation we actually demand. The default license # is a favour to our end users; if you happen to get that in a core face diff --git a/lib/puppet/interface/face_collection.rb b/lib/puppet/interface/face_collection.rb index 12d3c56b1..4522824fd 100644 --- a/lib/puppet/interface/face_collection.rb +++ b/lib/puppet/interface/face_collection.rb @@ -1,8 +1,6 @@ require 'puppet/interface' module Puppet::Interface::FaceCollection - SEMVER_VERSION = /^(\d+)\.(\d+)\.(\d+)([A-Za-z][0-9A-Za-z-]*|)$/ - @faces = Hash.new { |hash, key| hash[key] = {} } def self.faces @@ -17,55 +15,18 @@ module Puppet::Interface::FaceCollection @faces.keys.select {|name| @faces[name].length > 0 } end - def self.validate_version(version) - !!(SEMVER_VERSION =~ version.to_s) - end - - def self.semver_to_array(v) - parts = SEMVER_VERSION.match(v).to_a[1..4] - parts[0..2] = parts[0..2].map { |e| e.to_i } - parts - end - - def self.cmp_semver(a, b) - a, b = [a, b].map do |x| semver_to_array(x) end - - cmp = a[0..2] <=> b[0..2] - if cmp == 0 - cmp = a[3] <=> b[3] - cmp = +1 if a[3].empty? && !b[3].empty? - cmp = -1 if b[3].empty? && !a[3].empty? - end - cmp - end - - def self.prefix_match?(desired, target) - # Can't meaningfully do a prefix match with current on either side. - return false if desired == :current - return false if target == :current - - # REVISIT: Should probably fail if the matcher is not valid. - prefix = desired.split('.').map {|x| x =~ /^\d+$/ and x.to_i } - have = semver_to_array(target) - - while want = prefix.shift do - return false unless want == have.shift - end - return true - end - def self.[](name, version) name = underscorize(name) get_face(name, version) or load_face(name, version) end # get face from memory, without loading. - def self.get_face(name, desired_version) + def self.get_face(name, pattern) return nil unless @faces.has_key? name + return @faces[name][:current] if pattern == :current - return @faces[name][:current] if desired_version == :current - - found = @faces[name].keys.select {|v| prefix_match?(desired_version, v) }.sort.last + versions = @faces[name].keys - [ :current ] + found = SemVer.find_matching(pattern, versions) return @faces[name][found] end @@ -108,7 +69,7 @@ module Puppet::Interface::FaceCollection # versions here and return the last item in that set. # # --daniel 2011-04-06 - latest_ver = @faces[name].keys.sort {|a, b| cmp_semver(a, b) }.last + latest_ver = @faces[name].keys.sort.last @faces[name][:current] = @faces[name][latest_ver] end rescue LoadError => e diff --git a/lib/semver.rb b/lib/semver.rb new file mode 100644 index 000000000..ef9435abd --- /dev/null +++ b/lib/semver.rb @@ -0,0 +1,65 @@ +class SemVer + VERSION = /^v?(\d+)\.(\d+)\.(\d+)([A-Za-z][0-9A-Za-z-]*|)$/ + SIMPLE_RANGE = /^v?(\d+|[xX])(?:\.(\d+|[xX])(?:\.(\d+|[xX]))?)?$/ + + include Comparable + + def self.valid?(ver) + VERSION =~ ver + end + + def self.find_matching(pattern, versions) + versions.select { |v| v.matched_by?("#{pattern}") }.sort.last + end + + attr_reader :major, :minor, :tiny, :special + + def initialize(ver) + unless SemVer.valid?(ver) + raise ArgumentError.new("Invalid version string '#{ver}'!") + end + + @major, @minor, @tiny, @special = VERSION.match(ver).captures.map do |x| + # Because Kernel#Integer tries to interpret hex and octal strings, which + # we specifically do not want, and which cannot be overridden in 1.8.7. + Float(x).to_i rescue x + end + end + + def <=>(other) + other = SemVer.new("#{other}") unless other.is_a? SemVer + return self.major <=> other.major unless self.major == other.major + return self.minor <=> other.minor unless self.minor == other.minor + return self.tiny <=> other.tiny unless self.tiny == other.tiny + + return 0 if self.special == other.special + return 1 if self.special == '' + return -1 if other.special == '' + + return self.special <=> other.special + end + + def matched_by?(pattern) + # For the time being, this is restricted to exact version matches and + # simple range patterns. In the future, we should implement some or all of + # the comparison operators here: + # https://github.com/isaacs/node-semver/blob/d474801/semver.js#L340 + + case pattern + when SIMPLE_RANGE + pattern = SIMPLE_RANGE.match(pattern).captures + pattern[1] = @minor unless pattern[1] && pattern[1] !~ /x/i + pattern[2] = @tiny unless pattern[2] && pattern[2] !~ /x/i + [@major, @minor, @tiny] == pattern.map { |x| x.to_i } + when VERSION + self == SemVer.new(pattern) + else + false + end + end + + def inspect + "v#{@major}.#{@minor}.#{@tiny}#{@special}" + end + alias :to_s :inspect +end diff --git a/spec/unit/interface/face_collection_spec.rb b/spec/unit/interface/face_collection_spec.rb index 4ad8787c5..98887a778 100755 --- a/spec/unit/interface/face_collection_spec.rb +++ b/spec/unit/interface/face_collection_spec.rb @@ -25,39 +25,9 @@ describe Puppet::Interface::FaceCollection do @original_required.each {|f| $".push f unless $".include? f } end - describe "::prefix_match?" do - # want have - { ['1.0.0', '1.0.0'] => true, - ['1.0', '1.0.0'] => true, - ['1', '1.0.0'] => true, - ['1.0.0', '1.1.0'] => false, - ['1.0', '1.1.0'] => false, - ['1', '1.1.0'] => true, - ['1.0.1', '1.0.0'] => false, - }.each do |data, result| - it "should return #{result.inspect} for prefix_match?(#{data.join(', ')})" do - subject.prefix_match?(*data).should == result - end - end - end - - describe "::validate_version" do - { '10.10.10' => true, - '1.2.3.4' => false, - '10.10.10beta' => true, - '10.10' => false, - '123' => false, - 'v1.1.1' => false, - }.each do |input, result| - it "should#{result ? '' : ' not'} permit #{input.inspect}" do - subject.validate_version(input).should(result ? be_true : be_false) - end - end - end - describe "::[]" do before :each do - subject.instance_variable_get("@faces")[:foo]['0.0.1'] = 10 + subject.instance_variable_get("@faces")[:foo][SemVer.new('0.0.1')] = 10 end it "should return the face with the given name" do @@ -75,13 +45,13 @@ describe Puppet::Interface::FaceCollection do end it "should return true if the face specified is registered" do - subject.instance_variable_get("@faces")[:foo]['0.0.1'] = 10 + subject.instance_variable_get("@faces")[:foo][SemVer.new('0.0.1')] = 10 subject["foo", '0.0.1'].should == 10 end it "should attempt to require the face if it is not registered" do subject.expects(:require).with do |file| - subject.instance_variable_get("@faces")[:bar]['0.0.1'] = true + subject.instance_variable_get("@faces")[:bar][SemVer.new('0.0.1')] = true file == 'puppet/face/bar' end subject["bar", '0.0.1'].should be_true @@ -131,7 +101,9 @@ describe Puppet::Interface::FaceCollection do it "should store the face by name" do face = Puppet::Face.new(:my_face, '0.0.1') subject.register(face) - subject.instance_variable_get("@faces").should == {:my_face => {'0.0.1' => face}} + subject.instance_variable_get("@faces").should == { + :my_face => { face.version => face } + } end end diff --git a/spec/unit/semver_spec.rb b/spec/unit/semver_spec.rb new file mode 100644 index 000000000..0e0457b6e --- /dev/null +++ b/spec/unit/semver_spec.rb @@ -0,0 +1,187 @@ +require 'spec_helper' +require 'semver' + +describe SemVer do + describe '::valid?' do + it 'should validate basic version strings' do + %w[ 0.0.0 999.999.999 v0.0.0 v999.999.999 ].each do |vstring| + SemVer.valid?(vstring).should be_true + end + end + + it 'should validate special version strings' do + %w[ 0.0.0foo 999.999.999bar v0.0.0a v999.999.999beta ].each do |vstring| + SemVer.valid?(vstring).should be_true + end + end + + it 'should fail to validate invalid version strings' do + %w[ nope 0.0foo 999.999 x0.0.0 z.z.z 1.2.3-beta 1.x.y ].each do |vstring| + SemVer.valid?(vstring).should be_false + end + end + end + + describe '::find_matching' do + before :all do + @versions = %w[ + 0.0.1 + 0.0.2 + 1.0.0rc1 + 1.0.0rc2 + 1.0.0 + 1.0.1 + 1.1.0 + 1.1.1 + 1.1.2 + 1.1.3 + 1.1.4 + 1.2.0 + 1.2.1 + 2.0.0rc1 + ].map { |v| SemVer.new(v) } + end + + it 'should match exact versions by string' do + @versions.each do |version| + SemVer.find_matching(version, @versions).should == version + end + end + + it 'should return nil if no versions match' do + %w[ 3.0.0 2.0.0rc2 1.0.0alpha ].each do |v| + SemVer.find_matching(v, @versions).should be_nil + end + end + + it 'should find the greatest match for partial versions' do + SemVer.find_matching('1.0', @versions).should == 'v1.0.1' + SemVer.find_matching('1.1', @versions).should == 'v1.1.4' + SemVer.find_matching('1', @versions).should == 'v1.2.1' + SemVer.find_matching('2', @versions).should == 'v2.0.0rc1' + SemVer.find_matching('2.1', @versions).should == nil + end + + + it 'should find the greatest match for versions with placeholders' do + SemVer.find_matching('1.0.x', @versions).should == 'v1.0.1' + SemVer.find_matching('1.1.x', @versions).should == 'v1.1.4' + SemVer.find_matching('1.x', @versions).should == 'v1.2.1' + SemVer.find_matching('1.x.x', @versions).should == 'v1.2.1' + SemVer.find_matching('2.x', @versions).should == 'v2.0.0rc1' + SemVer.find_matching('2.x.x', @versions).should == 'v2.0.0rc1' + SemVer.find_matching('2.1.x', @versions).should == nil + end + end + + describe 'instantiation' do + it 'should raise an exception when passed an invalid version string' do + expect { SemVer.new('invalidVersion') }.to raise_exception ArgumentError + end + + it 'should populate the appropriate fields for a basic version string' do + version = SemVer.new('1.2.3') + version.major.should == 1 + version.minor.should == 2 + version.tiny.should == 3 + version.special.should == '' + end + + it 'should populate the appropriate fields for a special version string' do + version = SemVer.new('3.4.5beta6') + version.major.should == 3 + version.minor.should == 4 + version.tiny.should == 5 + version.special.should == 'beta6' + end + end + + describe '#matched_by?' do + subject { SemVer.new('v1.2.3beta') } + + describe 'should match against' do + describe 'literal version strings' do + it { should be_matched_by('1.2.3beta') } + + it { should_not be_matched_by('1.2.3alpha') } + it { should_not be_matched_by('1.2.4beta') } + it { should_not be_matched_by('1.3.3beta') } + it { should_not be_matched_by('2.2.3beta') } + end + + describe 'partial version strings' do + it { should be_matched_by('1.2.3') } + it { should be_matched_by('1.2') } + it { should be_matched_by('1') } + end + + describe 'version strings with placeholders' do + it { should be_matched_by('1.2.x') } + it { should be_matched_by('1.x.3') } + it { should be_matched_by('1.x.x') } + it { should be_matched_by('1.x') } + end + end + end + + describe 'comparisons' do + describe 'against a string' do + it 'should just work' do + SemVer.new('1.2.3').should == '1.2.3' + end + end + + describe 'against a symbol' do + it 'should just work' do + SemVer.new('1.2.3').should == :'1.2.3' + end + end + + describe 'on a basic version (v1.2.3)' do + subject { SemVer.new('v1.2.3') } + + it { should == SemVer.new('1.2.3') } + + # Different major versions + it { should > SemVer.new('0.2.3') } + it { should < SemVer.new('2.2.3') } + + # Different minor versions + it { should > SemVer.new('1.1.3') } + it { should < SemVer.new('1.3.3') } + + # Different tiny versions + it { should > SemVer.new('1.2.2') } + it { should < SemVer.new('1.2.4') } + + # Against special versions + it { should > SemVer.new('1.2.3beta') } + it { should < SemVer.new('1.2.4beta') } + end + + describe 'on a special version (v1.2.3beta)' do + subject { SemVer.new('v1.2.3beta') } + + it { should == SemVer.new('1.2.3beta') } + + # Same version, final release + it { should < SemVer.new('1.2.3') } + + # Different major versions + it { should > SemVer.new('0.2.3') } + it { should < SemVer.new('2.2.3') } + + # Different minor versions + it { should > SemVer.new('1.1.3') } + it { should < SemVer.new('1.3.3') } + + # Different tiny versions + it { should > SemVer.new('1.2.2') } + it { should < SemVer.new('1.2.4') } + + # Against special versions + it { should > SemVer.new('1.2.3alpha') } + it { should < SemVer.new('1.2.3beta2') } + end + end +end |
