diff --git a/python/lib/dependabot/python/requirement.rb b/python/lib/dependabot/python/requirement.rb index 69cb742ea6..257dbd8e6d 100644 --- a/python/lib/dependabot/python/requirement.rb +++ b/python/lib/dependabot/python/requirement.rb @@ -36,7 +36,17 @@ def self.parse(obj) line = matches[1] end - unless (matches = PATTERN.match(line)) + pattern = PATTERN + + if Dependabot::Experiments.enabled?(:python_new_version) + quoted = OPS.keys.sort_by(&:length).reverse + .map { |k| Regexp.quote(k) }.join("|") + version_pattern = Python::Version::NEW_VERSION_PATTERN + pattern_raw = "\\s*(?#{quoted})?\\s*(?#{version_pattern})\\s*".freeze + pattern = /\A#{pattern_raw}\z/ + end + + unless (matches = pattern.match(line)) msg = "Illformed requirement [#{obj.inspect}]" raise BadRequirementError, msg end @@ -128,7 +138,8 @@ def convert_caret_req(req_string) upper_bound = parts.map.with_index do |part, i| if i < first_non_zero_index then part elsif i == first_non_zero_index then (part.to_i + 1).to_s - elsif i > first_non_zero_index && i == 2 then "0.a" + # .dev has lowest precedence: https://packaging.python.org/en/latest/specifications/version-specifiers/#summary-of-permitted-suffixes-and-relative-ordering + elsif i > first_non_zero_index && i == 2 then "0.dev" else 0 end @@ -151,7 +162,7 @@ def convert_wildcard(req_string) .first(req_string.split(".").index { |s| s.include?("*") } + 1) .join(".") .gsub(/\*(?!$)/, "0") - .gsub(/\*$/, "0.a") + .gsub(/\*$/, "0.dev") .tap { |s| exact_op ? s.gsub!(/^(?") : s } end end diff --git a/python/lib/dependabot/python/version.rb b/python/lib/dependabot/python/version.rb index 419502301f..adbe980cf3 100644 --- a/python/lib/dependabot/python/version.rb +++ b/python/lib/dependabot/python/version.rb @@ -4,71 +4,333 @@ require "dependabot/version" require "dependabot/utils" -# Python versions can include a local version identifier, which Ruby can't -# parse. This class augments Gem::Version with local version identifier info. -# See https://www.python.org/dev/peps/pep-0440 for details. +# See https://packaging.python.org/en/latest/specifications/version-specifiers for spec details. module Dependabot module Python class Version < Dependabot::Version + sig { returns(Integer) } attr_reader :epoch + + sig { returns(T::Array[Integer]) } + attr_reader :release_segment + + sig { returns(T.nilable(T::Array[T.any(String, Integer)])) } + attr_reader :dev + + sig { returns(T.nilable(T::Array[T.any(String, Integer)])) } + attr_reader :pre + + sig { returns(T.nilable(T::Array[T.any(String, Integer)])) } + attr_reader :post + + sig { returns(T.nilable(T::Array[T.any(String, Integer)])) } + attr_reader :local + attr_reader :local_version attr_reader :post_release_version + INFINITY = 1000 + NEGATIVE_INFINITY = -INFINITY + # See https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions + NEW_VERSION_PATTERN = / + v? + (?: + (?:(?[0-9]+)!)? # epoch + (?[0-9]+(?:\.[0-9]+)*) # release + (?
                                         # prerelease
+            [-_\.]?
+            (?(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?[0-9]+)?
+          )?
+          (?                                        # post release
+            (?:-(?[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?post|rev|r)
+                [-_\.]?
+                (?[0-9]+)?
+            )
+          )?
+          (?                                          # dev release
+            [-_\.]?
+            (?dev)
+            [-_\.]?
+            (?[0-9]+)?
+          )?
+        )
+        (?:\+(?[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?    # local version
+      /ix
+
       VERSION_PATTERN = 'v?([1-9][0-9]*!)?[0-9]+[0-9a-zA-Z]*(?>\.[0-9a-zA-Z]+)*' \
                         '(-[0-9A-Za-z]+(\.[0-9a-zA-Z]+)*)?' \
                         '(\+[0-9a-zA-Z]+(\.[0-9a-zA-Z]+)*)?'
-      ANCHORED_VERSION_PATTERN = /\A\s*(#{VERSION_PATTERN})?\s*\z/
 
+      ANCHORED_VERSION_PATTERN = /\A\s*#{VERSION_PATTERN}\s*\z/
+
+      sig { override.params(version: VersionParameter).returns(T::Boolean) }
       def self.correct?(version)
         return false if version.nil?
 
-        version.to_s.match?(ANCHORED_VERSION_PATTERN)
+        if Dependabot::Experiments.enabled?(:python_new_version)
+          version.to_s.match?(/\A\s*#{NEW_VERSION_PATTERN}\s*\z/o)
+        else
+          version.to_s.match?(ANCHORED_VERSION_PATTERN)
+        end
       end
 
-      def initialize(version)
+      sig { override.params(version: VersionParameter).void }
+      def initialize(version) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity
+        raise Dependabot::BadRequirementError, "Malformed version string - string is nil" if version.nil?
+
         @version_string = version.to_s
-        version, @local_version = @version_string.split("+")
-        version ||= ""
-        version = version.gsub(/^v/, "")
-        if version.include?("!")
-          @epoch, version = version.split("!")
+
+        raise Dependabot::BadRequirementError, "Malformed version string - string is empty" if @version_string.empty?
+
+        matches = anchored_version_pattern.match(@version_string.downcase)
+
+        unless matches
+          raise Dependabot::BadRequirementError,
+                "Malformed version string - #{@version_string} does not match regex"
+        end
+
+        if Dependabot::Experiments.enabled?(:python_new_version)
+          @epoch = matches["epoch"].to_i
+          @release_segment = matches["release"]&.split(".")&.map(&:to_i) || []
+          @pre = parse_letter_version(matches["pre_l"], matches["pre_n"])
+          @post = parse_letter_version(matches["post_l"], matches["post_n1"] || matches["post_n2"])
+          @dev = parse_letter_version(matches["dev_l"], matches["dev_n"])
+          @local = parse_local_version(matches["local"])
+          super(matches["release"] || "")
         else
-          @epoch = "0"
+          version, @local_version = @version_string.split("+")
+          version ||= ""
+          version = version.gsub(/^v/, "")
+          if version.include?("!")
+            epoch, version = version.split("!")
+            @epoch = epoch.to_i
+          else
+            @epoch = 0
+          end
+          version = normalise_prerelease(version)
+          version, @post_release_version = version.split(/\.r(?=\d)/)
+          version ||= ""
+          @local_version = normalise_prerelease(@local_version) if @local_version
+          super
         end
-        version = normalise_prerelease(version)
-        version, @post_release_version = version.split(/\.r(?=\d)/)
-        version ||= ""
-        @local_version = normalise_prerelease(@local_version) if @local_version
-        super
       end
 
+      sig { override.params(version: VersionParameter).returns(Dependabot::Python::Version) }
+      def self.new(version)
+        T.cast(super, Dependabot::Python::Version)
+      end
+
+      sig { returns(String) }
       def to_s
         @version_string
       end
 
+      sig { returns(String) }
       def inspect # :nodoc:
         "#<#{self.class} #{@version_string}>"
       end
 
-      def <=>(other)
-        other = Version.new(other.to_s) unless other.is_a?(Python::Version)
+      sig { returns(T::Boolean) }
+      def prerelease?
+        return super unless Dependabot::Experiments.enabled?(:python_new_version)
 
-        epoch_comparison = epoch_comparison(other)
-        return epoch_comparison unless epoch_comparison.zero?
+        !!(pre || dev)
+      end
+
+      sig { returns(T.any(Gem::Version, Dependabot::Python::Version)) }
+      def release
+        return super unless Dependabot::Experiments.enabled?(:python_new_version)
+
+        Dependabot::Python::Version.new(release_segment.join("."))
+      end
+
+      sig { params(other: VersionParameter).returns(Integer) }
+      def <=>(other) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity
+        other = Dependabot::Python::Version.new(other.to_s) unless other.is_a?(Dependabot::Python::Version)
+        other = T.cast(other, Dependabot::Python::Version)
 
-        version_comparison = super
-        return version_comparison unless version_comparison&.zero?
+        if Dependabot::Experiments.enabled?(:python_new_version)
+          epoch_comparison = epoch <=> other.epoch
+          return epoch_comparison unless epoch_comparison.zero?
 
-        post_version_comparison = post_version_comparison(other)
-        return post_version_comparison unless post_version_comparison.zero?
+          release_comparison = release_version_comparison(other)
+          return release_comparison unless release_comparison.zero?
+
+          pre_comparison = compare_keys(pre_cmp_key, other.pre_cmp_key)
+          return pre_comparison unless pre_comparison.zero?
+
+          post_comparison = compare_keys(post_cmp_key, other.post_cmp_key)
+          return post_comparison unless post_comparison.zero?
+
+          dev_comparison = compare_keys(dev_cmp_key, other.dev_cmp_key)
+          return dev_comparison unless dev_comparison.zero?
+
+          compare_keys(local_cmp_key, other.local_cmp_key)
+        else
+          epoch_comparison = epoch_comparison(other)
+          return epoch_comparison unless epoch_comparison.zero?
 
-        local_version_comparison(other)
+          version_comparison = super
+          return T.must(version_comparison) unless version_comparison&.zero?
+
+          post_version_comparison = post_version_comparison(other)
+          return post_version_comparison unless post_version_comparison.zero?
+
+          local_version_comparison(other)
+        end
+      end
+
+      sig do
+        params(
+          key: T.any(Integer, T::Array[T.any(String, Integer)]),
+          other_key: T.any(Integer, T::Array[T.any(String, Integer)])
+        ).returns(Integer)
+      end
+      def compare_keys(key, other_key)
+        if key.is_a?(Integer) && other_key.is_a?(Integer)
+          key <=> other_key
+        elsif key.is_a?(Array) && other_key.is_a?(Array)
+          key <=> other_key
+        elsif key.is_a?(Integer)
+          key == NEGATIVE_INFINITY ? -1 : 1
+        elsif other_key.is_a?(Integer)
+          other_key == NEGATIVE_INFINITY ? 1 : -1
+        end
+      end
+
+      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
+      def pre_cmp_key
+        if pre.nil? && post.nil? && dev # sort 1.0.dev0 before 1.0a0
+          NEGATIVE_INFINITY
+        elsif pre.nil?
+          INFINITY # versions without a pre-release should sort after those with one.
+        else
+          T.must(pre)
+        end
+      end
+
+      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
+      def local_cmp_key
+        if local.nil?
+          # Versions without a local segment should sort before those with one.
+          NEGATIVE_INFINITY
+        else
+          # According to PEP440.
+          # - Alphanumeric segments sort before numeric segments
+          # - Alphanumeric segments sort lexicographically
+          # - Numeric segments sort numerically
+          # - Shorter versions sort before longer versions when the prefixes match exactly
+          local&.map do |token|
+            if token.is_a?(Integer)
+              [token, ""]
+            else
+              [NEGATIVE_INFINITY, token]
+            end
+          end
+        end
+      end
+
+      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
+      def post_cmp_key
+        # Versions without a post segment should sort before those with one.
+        return NEGATIVE_INFINITY if post.nil?
+
+        T.must(post)
+      end
+
+      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
+      def dev_cmp_key
+        # Versions without a dev segment should sort after those with one.
+        return INFINITY if dev.nil?
+
+        T.must(dev)
       end
 
       private
 
+      sig { params(other: Dependabot::Python::Version).returns(Integer) }
+      def release_version_comparison(other)
+        tokens, other_tokens = pad_for_comparison(release_segment, other.release_segment)
+        tokens <=> other_tokens
+      end
+
+      sig do
+        params(
+          tokens: T::Array[Integer],
+          other_tokens: T::Array[Integer]
+        ).returns(T::Array[T::Array[Integer]])
+      end
+      def pad_for_comparison(tokens, other_tokens)
+        tokens = tokens.dup
+        other_tokens = other_tokens.dup
+
+        longer = [tokens, other_tokens].max_by(&:count)
+        shorter = [tokens, other_tokens].min_by(&:count)
+
+        difference = T.must(longer).length - T.must(shorter).length
+
+        difference.times { T.must(shorter) << 0 }
+
+        [tokens, other_tokens]
+      end
+
+      sig { params(local: T.nilable(String)).returns(T.nilable(T::Array[T.any(String, Integer)])) }
+      def parse_local_version(local)
+        return if local.nil?
+
+        # Takes a string like abc.1.twelve and turns it into ["abc", 1, "twelve"]
+        local.split(/[\._-]/).map { |s| /^\d+$/.match?(s) ? s.to_i : s }
+      end
+
+      sig do
+        params(
+          letter: T.nilable(String), number: T.nilable(String)
+        ).returns(T.nilable(T::Array[T.any(String, Integer)]))
+      end
+      def parse_letter_version(letter = nil, number = nil)
+        return if letter.nil? && number.nil?
+
+        if letter
+          # Implicit 0 for cases where prerelease has no numeral
+          number ||= 0
+
+          # Normalize alternate spellings
+          if letter == "alpha"
+            letter = "a"
+          elsif letter == "beta"
+            letter = "b"
+          elsif %w(c pre preview).include? letter
+            letter = "rc"
+          elsif %w(rev r).include? letter
+            letter = "post"
+          end
+
+          return letter, number.to_i
+        end
+
+        # Number but no letter i.e. implicit post release syntax (e.g. 1.0-1)
+        letter = "post"
+
+        [letter, number.to_i]
+      end
+
+      sig { returns(Regexp) }
+      def anchored_version_pattern
+        if Dependabot::Experiments.enabled?(:python_new_version)
+          /\A\s*#{NEW_VERSION_PATTERN}\s*\z/o
+        else
+          ANCHORED_VERSION_PATTERN
+        end
+      end
+
       def epoch_comparison(other)
         epoch.to_i <=> other.epoch.to_i
       end
diff --git a/python/spec/dependabot/python/requirement_spec.rb b/python/spec/dependabot/python/requirement_spec.rb
index 4ac72b51d3..855d5b2f70 100644
--- a/python/spec/dependabot/python/requirement_spec.rb
+++ b/python/spec/dependabot/python/requirement_spec.rb
@@ -77,31 +77,31 @@
     context "with a ^" do
       let(:requirement_string) { "^1.2.3" }
 
-      it { is_expected.to eq(described_class.new(">= 1.2.3", "< 2.0.0.a")) }
+      it { is_expected.to eq(described_class.new(">= 1.2.3", "< 2.0.0.dev")) }
 
       context "when dealing with two digits" do
         let(:requirement_string) { "^1.2" }
 
-        it { is_expected.to eq(described_class.new(">= 1.2", "< 2.0.0.a")) }
+        it { is_expected.to eq(described_class.new(">= 1.2", "< 2.0.0.dev")) }
       end
 
       context "with a pre-1.0.0 dependency" do
         let(:requirement_string) { "^0.2.3" }
 
-        it { is_expected.to eq(described_class.new(">= 0.2.3", "< 0.3.0.a")) }
+        it { is_expected.to eq(described_class.new(">= 0.2.3", "< 0.3.0.dev")) }
       end
     end
 
     context "with an *" do
       let(:requirement_string) { "== 1.3.*" }
 
-      its(:to_s) { is_expected.to eq(Gem::Requirement.new("~> 1.3.0.a").to_s) }
+      its(:to_s) { is_expected.to eq(Gem::Requirement.new("~> 1.3.0.dev").to_s) }
 
       context "without a prefix" do
         let(:requirement_string) { "1.3.*" }
 
         its(:to_s) do
-          is_expected.to eq(Gem::Requirement.new("~> 1.3.0.a").to_s)
+          is_expected.to eq(Gem::Requirement.new("~> 1.3.0.dev").to_s)
         end
       end
 
@@ -117,7 +117,7 @@
       context "with a >= op" do
         let(:requirement_string) { ">= 1.3.*" }
 
-        it { is_expected.to eq(described_class.new(">= 1.3.a")) }
+        it { is_expected.to eq(described_class.new(">= 1.3.dev")) }
       end
     end
 
@@ -163,7 +163,7 @@
       let(:requirement_string) { ["== 1.3.*", ">= 1.3.1"] }
 
       its(:to_s) do
-        is_expected.to eq(Gem::Requirement.new(["~> 1.3.0.a", ">= 1.3.1"]).to_s)
+        is_expected.to eq(Gem::Requirement.new(["~> 1.3.0.dev", ">= 1.3.1"]).to_s)
       end
     end
 
diff --git a/python/spec/dependabot/python/version_spec.rb b/python/spec/dependabot/python/version_spec.rb
index 60a1a2a467..11f806cd9d 100644
--- a/python/spec/dependabot/python/version_spec.rb
+++ b/python/spec/dependabot/python/version_spec.rb
@@ -12,6 +12,10 @@
   describe ".correct?" do
     subject { described_class.correct?(version_string) }
 
+    before do
+      Dependabot::Experiments.register(:python_new_version, true)
+    end
+
     context "with a valid version" do
       let(:version_string) { "1.0.0" }
 
@@ -48,32 +52,72 @@
       it { is_expected.to be(false) }
     end
 
-    context "with an invalid version" do
-      let(:version_string) { "bad" }
+    context "with an empty version" do
+      let(:version_string) { "" }
 
       it { is_expected.to be(false) }
+    end
 
-      context "when version includes an invalid local version" do
-        let(:version_string) { "1.0.0+abc 123" }
+    context "with invalid versions" do
+      versions = [
+        "bad",
+        "1.0+a+",
+        "1.0++",
+        "1.0+_foobar",
+        "1.0+foo&asd",
+        "1.0+1+1",
+        "1.0.0+abc 123",
+        "v1.8.0--failed-release-attempt"
+      ]
+
+      versions.each do |version|
+        it "returns false for #{version}" do
+          expect(described_class.correct?(version)).to be false
+        end
+      end
+    end
+  end
 
-        it { is_expected.to be(false) }
+  describe ".new" do
+    subject(:version) { described_class.new(version_string) }
+
+    before do
+      Dependabot::Experiments.register(:python_new_version, true)
+    end
+
+    context "with an empty string" do
+      let(:version_string) { "" }
+      let(:error_msg) { "Malformed version string - string is empty" }
+
+      it "raises an error" do
+        expect { version }.to raise_error(Dependabot::BadRequirementError, error_msg)
       end
+    end
 
-      context "when version includes two dashes" do
-        let(:version_string) { "v1.8.0--failed-release-attempt" }
+    context "with a nil version" do
+      let(:version_string) { nil }
+      let(:error_msg) { "Malformed version string - string is nil" }
 
-        it { is_expected.to be(false) }
+      it "raises an error" do
+        expect { version }.to raise_error(Dependabot::BadRequirementError, error_msg)
       end
     end
-  end
 
-  describe ".new" do
-    subject { described_class.new(version_string) }
+    context "with a valid version" do
+      let(:version_string) { "1!1.3b2.post345.dev456" }
 
-    context "with a blank string" do
-      let(:version_string) { "" }
+      it "is parsed correctly" do
+        expect(version.epoch).to eq 1
+      end
+    end
+
+    context "with an invalid version" do
+      let(:version_string) { "1.0++" }
+      let(:error_msg) { "Malformed version string - #{version_string} does not match regex" }
 
-      it { is_expected.to eq(Gem::Version.new("0")) }
+      it "raises an error" do
+        expect { version }.to raise_error(Dependabot::BadRequirementError, error_msg)
+      end
     end
   end
 
@@ -95,7 +139,6 @@
 
   describe "#<=>" do
     sorted_versions = [
-      "",
       "0.9",
       "1.0.0-alpha",
       "1.0.0-a.1",
@@ -104,8 +147,8 @@
       "1.0.0-beta.11",
       "1.0.0-rc.1",
       "1",
-      # "1.0.0.post", TODO fails comparing to 1
       "1.0.0+gc1",
+      # "1.0.0.post", TODO fails comparing to 1
       "1.post2",
       "1.post2+gc1",
       "1.post2+gc1.2",
@@ -118,6 +161,7 @@
       "2!0.1.0",
       "10!0.1.0"
     ]
+
     sorted_versions.combination(2).each do |lhs, rhs|
       it "'#{lhs}' < '#{rhs}'" do
         expect(described_class.new(lhs)).to be < rhs
@@ -130,19 +174,135 @@
         expect(described_class.new(v)).to eq v
       end
     end
+
+    context "when sorting a list of versions" do
+      let(:version_strings) do
+        [
+          # Implicit epoch of 0
+          "1.0.dev456",
+          "1.0a1",
+          "1.0a2.dev456",
+          "1.0a12.dev456",
+          "1.0a12",
+          "1.0b1.dev456",
+          "1.0b2",
+          "1.0b2.post345.dev456",
+          "1.0b2.post345",
+          "1.0b2-346",
+          "1.0c1.dev456",
+          "1.0c1",
+          "1.0rc2",
+          "1.0c3",
+          "1.0",
+          "1.0.post456.dev34",
+          "1.0.post456",
+          "1.1.dev1",
+          "1.2+123abc",
+          "1.2+123abc456",
+          "1.2+abc",
+          "1.2+abc123",
+          "1.2+abc123def",
+          "1.2+1234.abc",
+          "1.2+123456",
+          "1.2.r32+123456",
+          "1.2.rev33+123456",
+          # Explicit epoch of 1
+          "1!1.0.dev456",
+          "1!1.0a1",
+          "1!1.0a2.dev456",
+          "1!1.0a12.dev456",
+          "1!1.0a12",
+          "1!1.0b1.dev456",
+          "1!1.0b2",
+          "1!1.0b2.post345.dev456",
+          "1!1.0b2.post345",
+          "1!1.0b2-346",
+          "1!1.0c1.dev456",
+          "1!1.0c1",
+          "1!1.0rc2",
+          "1!1.0c3",
+          "1!1.0",
+          "1!1.0.post456.dev34",
+          "1!1.0.post456",
+          "1!1.1.dev1",
+          "1!1.2+123abc",
+          "1!1.2+123abc456",
+          "1!1.2+abc",
+          "1!1.2+abc123",
+          "1!1.2+abc123def",
+          "1!1.2+1234.abc",
+          "1!1.2+123456",
+          "1!1.2.r32+123456",
+          "1!1.2.rev33+123456"
+        ]
+      end
+
+      let(:versions) { version_strings.map { |v| described_class.new(v) } }
+
+      before do
+        Dependabot::Experiments.register(:python_new_version, true)
+      end
+
+      it "returns list in the correct order" do
+        expect(versions.shuffle.sort).to eq versions
+      end
+    end
+
     it "handles missing version segments" do
       expect(described_class.new("1")).to eq "v1.0"
       expect(described_class.new("1")).to eq "v1.0.0"
     end
+
+    context "with different epochs" do
+      let(:version) { described_class.new("1!1.0.dev456") }
+      let(:other_version) { described_class.new("1.0.dev456") }
+
+      it "compares correctly" do
+        expect(version <=> other_version).to eq 1
+      end
+    end
+
+    context "with equivalent release candidates" do
+      let(:version) { described_class.new("1.0rc1") }
+      let(:other_version) { described_class.new("1.0c1") }
+
+      it "returns 0" do
+        expect(version <=> other_version).to eq 0
+      end
+    end
   end
 
   describe "#prerelease?" do
     subject { version.prerelease? }
 
     context "with a prerelease" do
-      let(:version_string) { "1.0.0alpha" }
-
-      it { is_expected.to be(true) }
+      versions = [
+        "1.0.dev0",
+        "1.0.dev1",
+        "1.0a1.dev1",
+        "1.0b1.dev1",
+        "1.0c1.dev1",
+        "1.0rc1.dev1",
+        "1.0a1",
+        "1.0b1",
+        "1.0c1",
+        "1.0rc1",
+        "1.0a1.post1.dev1",
+        "1.0b1.post1.dev1",
+        "1.0c1.post1.dev1",
+        "1.0rc1.post1.dev1",
+        "1.0a1.post1",
+        "1.0b1.post1",
+        "1.0c1.post1",
+        "1.0rc1.post1",
+        "1!1.0a1"
+      ]
+
+      versions.each do |version|
+        it "returns true for #{version}" do
+          expect(described_class.new(version).prerelease?).to be true
+        end
+      end
     end
 
     context "with a normal release" do
@@ -168,6 +328,18 @@
         it { is_expected.to be(false) }
       end
     end
+
+    context "with a local release" do
+      let(:version_string) { "1.0+dev" }
+
+      it { is_expected.to be(false) }
+    end
+
+    context "with a local post release" do
+      let(:version_string) { "1.0.post1+dev" }
+
+      it { is_expected.to be(false) }
+    end
   end
 
   describe "compatibility with Gem::Requirement" do