diff --git a/python/lib/dependabot/python/file_parser.rb b/python/lib/dependabot/python/file_parser.rb index d727879218..6974103342 100644 --- a/python/lib/dependabot/python/file_parser.rb +++ b/python/lib/dependabot/python/file_parser.rb @@ -8,9 +8,12 @@ require "dependabot/shared_helpers" require "dependabot/python/requirement" require "dependabot/errors" +require "dependabot/python/language" require "dependabot/python/native_helpers" require "dependabot/python/name_normaliser" require "dependabot/python/pip_compile_file_matcher" +require "dependabot/python/language_version_manager" +require "dependabot/python/package_manager" module Dependabot module Python @@ -34,6 +37,11 @@ class FileParser < Dependabot::FileParsers::Base InvalidRequirement ValueError RecursionError ).freeze + # we use this placeholder version in case we are not able to detect any + # PIP version from shell, we are ensuring that the actual update is not blocked + # in any way if any metric collection exception start happening + UNDETECTED_PACKAGE_MANAGER_VERSION = "0.0" + def parse # TODO: setup.py from external dependencies is evaluated. Provide guards before removing this. raise Dependabot::UnexpectedExternalCode if @reject_external_code @@ -48,8 +56,92 @@ def parse dependency_set.dependencies end + sig { returns(Ecosystem) } + def ecosystem + @ecosystem ||= T.let( + Ecosystem.new( + name: ECOSYSTEM, + package_manager: package_manager, + language: language + ), + T.nilable(Ecosystem) + ) + end + private + def language_version_manager + @language_version_manager ||= + LanguageVersionManager.new( + python_requirement_parser: python_requirement_parser + ) + end + + def python_requirement_parser + @python_requirement_parser ||= + FileParser::PythonRequirementParser.new( + dependency_files: dependency_files + ) + end + + sig { returns(Ecosystem::VersionManager) } + def package_manager + @package_manager ||= detected_package_manager + end + + sig { returns(Ecosystem::VersionManager) } + def detected_package_manager + return PeotryPackageManager.new(detect_poetry_version) if poetry_lock && detect_poetry_version + + PipPackageManager.new(detect_pip_version) + end + + def detect_poetry_version + if poetry_lock + version = SharedHelpers.run_shell_command("pyenv exec poetry --version") + .to_s.split("version ").last&.split(")")&.first + + log_if_version_malformed(PeotryPackageManager.name, version) + + # makes sure we have correct version format returned + version if version&.match?(/^\d+(?:\.\d+)*$/) + + end + rescue StandardError + nil + end + + def detect_pip_version + # extracts pip version from current python via executing shell command + version = SharedHelpers.run_shell_command("pyenv exec pip -V") + .split("from").first&.split("pip")&.last&.strip + + log_if_version_malformed(PipPackageManager.name, version) + + version&.match?(/^\d+(?:\.\d+)*$/) ? version : UNDETECTED_PACKAGE_MANAGER_VERSION + rescue StandardError + nil + end + + def log_if_version_malformed(package_manager, version) + # logs warning if malformed version is found + return true if version&.match?(/^\d+(?:\.\d+)*$/) + + Dependabot.logger.warn( + "Detected #{package_manager} with malformed version #{version}" + ) + end + + sig { returns(String) } + def python_raw_version + language_version_manager.python_version + end + + sig { returns(T.nilable(Ecosystem::VersionManager)) } + def language + Language.new(python_raw_version) + end + def requirement_files dependency_files.select { |f| f.name.end_with?(".txt", ".in") } end diff --git a/python/lib/dependabot/python/language.rb b/python/lib/dependabot/python/language.rb new file mode 100644 index 0000000000..c301428d92 --- /dev/null +++ b/python/lib/dependabot/python/language.rb @@ -0,0 +1,21 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/python/version" +require "dependabot/ecosystem" + +module Dependabot + module Python + LANGUAGE = "python" + + class Language < Dependabot::Ecosystem::VersionManager + extend T::Sig + + sig { params(raw_version: String, requirement: T.nilable(Requirement)).void } + def initialize(raw_version, requirement = nil) + super(LANGUAGE, Version.new(raw_version), [], [], requirement) + end + end + end +end diff --git a/python/lib/dependabot/python/package_manager.rb b/python/lib/dependabot/python/package_manager.rb new file mode 100644 index 0000000000..e84817f8de --- /dev/null +++ b/python/lib/dependabot/python/package_manager.rb @@ -0,0 +1,90 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/python/version" +require "dependabot/ecosystem" +require "dependabot/python/requirement" + +module Dependabot + module Python + ECOSYSTEM = "Python" + + # Keep versions in ascending order + SUPPORTED_PYTHON_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version]) + + DEPRECATED_PYTHON_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version]) + + class PipPackageManager < Dependabot::Ecosystem::VersionManager + extend T::Sig + + NAME = "pip" + + SUPPORTED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version]) + + DEPRECATED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version]) + + sig do + params( + raw_version: String, + requirement: T.nilable(Requirement) + ).void + end + def initialize(raw_version, requirement = nil) + super( + NAME, + Version.new(raw_version), + SUPPORTED_VERSIONS, + DEPRECATED_VERSIONS, + requirement, + ) + end + + sig { override.returns(T::Boolean) } + def deprecated? + false + end + + sig { override.returns(T::Boolean) } + def unsupported? + false + end + end + + class PeotryPackageManager < Dependabot::Ecosystem::VersionManager + extend T::Sig + + NAME = "poetry" + + SUPPORTED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version]) + + DEPRECATED_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version]) + + sig do + params( + raw_version: String, + requirement: T.nilable(Requirement) + ).void + end + def initialize(raw_version, requirement = nil) + super( + NAME, + Version.new(raw_version), + DEPRECATED_PYTHON_VERSIONS, + SUPPORTED_PYTHON_VERSIONS, + requirement, + ) + end + + sig { override.returns(T::Boolean) } + def deprecated? + false + end + + sig { override.returns(T::Boolean) } + def unsupported? + false + end + end + end +end diff --git a/python/spec/dependabot/python/peotry_package_manager_spec.rb b/python/spec/dependabot/python/peotry_package_manager_spec.rb new file mode 100644 index 0000000000..1523e813a8 --- /dev/null +++ b/python/spec/dependabot/python/peotry_package_manager_spec.rb @@ -0,0 +1,33 @@ +# typed: false +# frozen_string_literal: true + +require "dependabot/python/package_manager" +require "dependabot/ecosystem" +require "spec_helper" + +RSpec.describe Dependabot::Python::PeotryPackageManager do + let(:package_manager) { described_class.new("1.8.3") } + + describe "#initialize" do + context "when version is a String" do + it "sets the version correctly" do + expect(package_manager.version).to eq("1.8.3") + end + + it "sets the name correctly" do + expect(package_manager.name).to eq("poetry") + end + end + + context "when poetry version is extracted from pyenv is well formed" do + # If this test starts failing, you need to adjust the "detect_poetry_version" function + # to return a valid version in format x.x, x.x.x etc. examples: 3.12.5, 3.12 + version = Dependabot::SharedHelpers.run_shell_command("pyenv exec poetry --version") + .split("version ").last&.split(")")&.first + + it "does not raise error" do + expect(version.match(/^\d+(?:\.\d+)*$/)).to be_truthy + end + end + end +end diff --git a/python/spec/dependabot/python/pip_package_manager_spec.rb b/python/spec/dependabot/python/pip_package_manager_spec.rb new file mode 100644 index 0000000000..ab9534e21a --- /dev/null +++ b/python/spec/dependabot/python/pip_package_manager_spec.rb @@ -0,0 +1,33 @@ +# typed: false +# frozen_string_literal: true + +require "dependabot/python/package_manager" +require "dependabot/ecosystem" +require "spec_helper" + +RSpec.describe Dependabot::Python::PipPackageManager do + let(:package_manager) { described_class.new("24.0") } + + describe "#initialize" do + context "when version is a String" do + it "sets the version correctly" do + expect(package_manager.version).to eq("24.0") + end + + it "sets the name correctly" do + expect(package_manager.name).to eq("pip") + end + end + + context "when pip version is extracted from pyenv is well formed" do + # If this test starts failing, you need to adjust the "detect_pip_version" function + # to return a valid version in format x.x, x.x.x etc. examples: 3.12.5, 3.12 + version = Dependabot::SharedHelpers.run_shell_command("pyenv exec pip -V") + .split("from").first&.split("pip")&.last&.strip.to_s + + it "does not raise error" do + expect(version.match(/^\d+(?:\.\d+)*$/)).to be_truthy + end + end + end +end