diff --git a/python/lib/dependabot/python/requirement_parser.rb b/python/lib/dependabot/python/requirement_parser.rb index 8d229502ef..c1098b28c7 100644 --- a/python/lib/dependabot/python/requirement_parser.rb +++ b/python/lib/dependabot/python/requirement_parser.rb @@ -6,7 +6,7 @@ class RequirementParser NAME = /[a-zA-Z0-9](?:[a-zA-Z0-9\-_\.]*[a-zA-Z0-9])?/.freeze EXTRA = /[a-zA-Z0-9\-_\.]+/.freeze COMPARISON = /===|==|>=|<=|<|>|~=|!=/.freeze - VERSION = /[0-9]+[a-zA-Z0-9\-_\.*]*(\+[0-9a-zA-Z]+(\.[0-9a-zA-Z]+)*)?/. + VERSION = /([1-9][0-9]*!)?[0-9]+[a-zA-Z0-9\-_.*]*(\+[0-9a-zA-Z]+(\.[0-9a-zA-Z]+)*)?/. freeze REQUIREMENT = /(?#{COMPARISON})\s*\\?\s*(?#{VERSION})/.freeze diff --git a/python/lib/dependabot/python/version.rb b/python/lib/dependabot/python/version.rb index dd57b04b3d..525be66d6c 100644 --- a/python/lib/dependabot/python/version.rb +++ b/python/lib/dependabot/python/version.rb @@ -10,10 +10,12 @@ module Dependabot module Python class Version < Gem::Version + attr_reader :epoch attr_reader :local_version attr_reader :post_release_version - VERSION_PATTERN = 'v?[0-9]+[0-9a-zA-Z]*(?>\.[0-9a-zA-Z]+)*' \ + # See https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions + 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/.freeze @@ -29,6 +31,11 @@ def initialize(version) version, @local_version = version.split("+") version ||= "" version = version.gsub(/^v/, "") + if version.include?("!") + @epoch, version = version.split("!") + else + @epoch = "0" + end version = normalise_prerelease(version) version, @post_release_version = version.split(/\.r(?=\d)/) version ||= "" @@ -45,33 +52,37 @@ def inspect # :nodoc: end def <=>(other) + other = Version.new(other.to_s) unless other.is_a?(Python::Version) + + epoch_comparison = epoch_comparison(other) + return epoch_comparison unless epoch_comparison.zero? + version_comparison = super(other) return version_comparison unless version_comparison.zero? - return post_version_comparison(other) unless post_version_comparison(other).zero? + post_version_comparison = post_version_comparison(other) + return post_version_comparison unless post_version_comparison.zero? local_version_comparison(other) end + private + + def epoch_comparison(other) + epoch.to_i <=> other.epoch.to_i + end + def post_version_comparison(other) - unless other.is_a?(Python::Version) && other.post_release_version + unless other.post_release_version return post_release_version.nil? ? 0 : 1 end return -1 if post_release_version.nil? - # Post release versions should only ever be a single number, so we can - # just string-comparison them. - return 0 if post_release_version.to_i == other.post_release_version.to_i - - post_release_version.to_i > other.post_release_version.to_i ? 1 : -1 + post_release_version.to_i <=> other.post_release_version.to_i end def local_version_comparison(other) - unless other.is_a?(Python::Version) - return local_version.nil? ? 0 : 1 - end - # Local version comparison works differently in Python: `1.0.beta` # compares as greater than `1.0`. To accommodate, we make the # strings the same length before comparing. @@ -89,8 +100,6 @@ def local_version_comparison(other) lhsegments.count <=> rhsegments.count end - private - def normalise_prerelease(version) # Python has reserved words for release states, which are treated # as equal (e.g., preview, pre and rc). diff --git a/python/spec/dependabot/python/requirement_parser_spec.rb b/python/spec/dependabot/python/requirement_parser_spec.rb index 8a92f4f370..b0323b98ed 100644 --- a/python/spec/dependabot/python/requirement_parser_spec.rb +++ b/python/spec/dependabot/python/requirement_parser_spec.rb @@ -64,6 +64,13 @@ def parse(line) it { is_expected.to be_nil } end + context "with an epoch specification" do + let(:line) { "luigi==1!1.1.0" } + its([:requirements]) do + is_expected.to eq [{ comparison: "==", version: "1!1.1.0" }] + end + end + context "with a simple specification" do let(:line) { "luigi == 0.1.0" } its([:requirements]) do diff --git a/python/spec/dependabot/python/version_spec.rb b/python/spec/dependabot/python/version_spec.rb index 62ce5808fc..58ea798851 100644 --- a/python/spec/dependabot/python/version_spec.rb +++ b/python/spec/dependabot/python/version_spec.rb @@ -14,6 +14,11 @@ let(:version_string) { "1.0.0" } it { is_expected.to eq(true) } + context "that includes a non-zero epoch" do + let(:version_string) { "1!1.0.0" } + it { is_expected.to eq(true) } + end + context "that includes a local version" do let(:version_string) { "1.0.0+abc.1" } it { is_expected.to eq(true) } @@ -70,126 +75,46 @@ end describe "#<=>" do - subject { version <=> other_version } - - context "compared to a Gem::Version" do - context "that is lower" do - let(:other_version) { Gem::Version.new("0.9.0") } - it { is_expected.to eq(1) } - end - - context "that is equal" do - let(:other_version) { Gem::Version.new("1.0.0") } - it { is_expected.to eq(0) } - - context "but our version has a local version" do - let(:version_string) { "1.0.0+gc.1" } - it { is_expected.to eq(1) } - end - - context "but our version has a v-prefix" do - let(:version_string) { "v1.0.0" } - it { is_expected.to eq(0) } - end - end - - context "that is greater" do - let(:other_version) { Gem::Version.new("1.1.0") } - it { is_expected.to eq(-1) } + sorted_versions = [ + "", + "0.9", + "1.0.0-alpha", + "1.0.0-a.1", + "1.0.0-beta", + "1.0.0-b.2", + "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.post2", + "1.post2+gc1", + "1.post2+gc1.2", + "1.post2+gc1.11", + "1.0.0.post11", + "1.0.2", + "1.0.11", + "2016.1", + "1!0.1.0", + "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 + expect(described_class.new(rhs)).to be > lhs end end - context "compared to a Python::Version" do - context "that is lower" do - let(:other_version) { described_class.new("0.9.0") } - it { is_expected.to eq(1) } - end - - context "that is equal" do - let(:other_version) { described_class.new("1.0.0") } - it { is_expected.to eq(0) } - - context "but our version has a local version" do - let(:version_string) { "1.0.0+gc.1" } - it { is_expected.to eq(1) } - end - - context "with a prerelease specifier that needs normalising" do - let(:version_string) { "1.0.0c1" } - let(:other_version) { described_class.new("1.0.0rc1") } - it { is_expected.to eq(0) } - - context "with a dash that needs sanitizing" do - let(:version_string) { "0.5.4-alpha" } - let(:other_version) { described_class.new("0.5.4a0") } - it { is_expected.to eq(0) } - end - end - - context "with a post-release" do - let(:version_string) { "1.0.0-post1" } - it { is_expected.to eq(1) } - - context "that needs normalising" do - let(:other_version) { described_class.new("1.0.0-post1") } - let(:version_string) { "1.0.0rev1" } - it { is_expected.to eq(0) } - end - - context "that is being compared to another post release" do - let(:other_version) { described_class.new("1.0.0.post1") } - let(:version_string) { "1.0.0.post2" } - it { is_expected.to eq(1) } - end - - context "when the other version has a post release" do - let(:other_version) { described_class.new("1.0.0.post1") } - let(:version_string) { "1.0.0" } - it { is_expected.to eq(-1) } - end - end - - context "but the other version has a local version" do - let(:other_version) { described_class.new("1.0.0+gc.1") } - it { is_expected.to eq(-1) } - end - - context "and both sides have a local version" do - let(:other_version) { described_class.new("1.0.0+gc.1") } - - context "that is equal" do - let(:version_string) { "1.0.0+gc.1" } - it { is_expected.to eq(0) } - end - - context "when our side is greater" do - let(:version_string) { "1.0.0+gc.2" } - it { is_expected.to eq(1) } - end - - context "when our side is lower" do - let(:version_string) { "1.0.0+gc" } - it { is_expected.to eq(-1) } - end - - context "when our side is longer" do - let(:version_string) { "1.0.0+gc.1.1" } - it { is_expected.to eq(1) } - end - end - end - - context "that is greater" do - let(:other_version) { described_class.new("1.1.0") } - it { is_expected.to eq(-1) } - - context "with a dash that needs sanitizing" do - let(:version_string) { "0.5.4-alpha" } - let(:other_version) { described_class.new("0.5.4a1") } - it { is_expected.to eq(-1) } - end + sorted_versions.each do |v| + it "should equal itself #{v}" do + expect(described_class.new(v)).to eq v end end + it "should handle missing version segments" do + expect(described_class.new("1")).to eq "v1.0" + expect(described_class.new("1")).to eq "v1.0.0" + end end describe "#prerelease?" do