lib/dependabot/uv/language_version_manager.rb
# typed: strict # frozen_string_literal: true require "dependabot/logger" require "dependabot/uv/version" require "sorbet-runtime" module Dependabot module Uv class LanguageVersionManager extend T::Sig # This list must match the versions specified at the top of `python/Dockerfile` PRE_INSTALLED_PYTHON_VERSIONS = %w( 3.13.2 3.12.9 3.11.11 3.10.16 3.9.21 ).freeze sig { params(python_requirement_parser: T.untyped).void } def initialize(python_requirement_parser:) @python_requirement_parser = python_requirement_parser end sig { returns(T.nilable(String)) } def install_required_python # The leading space is important in the version check return if SharedHelpers.run_shell_command("pyenv versions").include?(" #{python_major_minor}.") SharedHelpers.run_shell_command( "tar -axf /usr/local/.pyenv/versions/#{python_version}.tar.zst -C /usr/local/.pyenv/versions" ) end sig { returns(String) } def installed_version # Use `pyenv exec` to query the active Python version output, _status = SharedHelpers.run_shell_command("pyenv exec python --version") version = output.strip.split.last # Extract the version number (e.g., "3.13.1") T.must(version) end sig { returns(T.untyped) } def python_major_minor @python_major_minor ||= T.let(T.must(Uv::Version.new(python_version).segments[0..1]).join("."), T.untyped) end sig { returns(String) } def python_version @python_version ||= T.let(python_version_from_supported_versions, T.nilable(String)) end sig { returns(String) } def python_requirement_string if user_specified_python_version if user_specified_python_version.start_with?(/\d/) parts = user_specified_python_version.split(".") parts.fill("*", (parts.length)..2).join(".") else user_specified_python_version end else python_version_matching_imputed_requirements || PRE_INSTALLED_PYTHON_VERSIONS.first end end sig { returns(String) } def python_version_from_supported_versions requirement_string = python_requirement_string # If the requirement string isn't already a range (eg ">3.10"), coerce it to "major.minor.*". # The patch version is ignored because a non-matching patch version is unlikely to affect resolution. requirement_string = requirement_string.gsub(/\.\d+$/, ".*") if requirement_string.start_with?(/\d/) # Try to match one of our pre-installed Python versions requirement = T.must(Uv::Requirement.requirements_array(requirement_string).first) version = PRE_INSTALLED_PYTHON_VERSIONS.find { |v| requirement.satisfied_by?(Uv::Version.new(v)) } return version if version # Otherwise we have to raise supported_versions = PRE_INSTALLED_PYTHON_VERSIONS.map { |x| x.gsub(/\.\d+$/, ".*") }.join(", ") raise ToolVersionNotSupported.new("Python", python_requirement_string, supported_versions) end sig { returns(T.untyped) } def user_specified_python_version @python_requirement_parser.user_specified_requirements.first end sig { returns(T.nilable(String)) } def python_version_matching_imputed_requirements compiled_file_python_requirement_markers = @python_requirement_parser.imputed_requirements.map do |r| Dependabot::Uv::Requirement.new(r) end python_version_matching(compiled_file_python_requirement_markers) end sig { params(requirements: T.untyped).returns(T.nilable(String)) } def python_version_matching(requirements) PRE_INSTALLED_PYTHON_VERSIONS.find do |version_string| version = Uv::Version.new(version_string) requirements.all? do |req| next req.any? { |r| r.satisfied_by?(version) } if req.is_a?(Array) req.satisfied_by?(version) end end end end end end