lib/dependabot/uv/file_updater/requirement_replacer.rb
# typed: true # frozen_string_literal: true require "dependabot/dependency" require "dependabot/uv/requirement_parser" require "dependabot/uv/file_updater" require "dependabot/shared_helpers" require "dependabot/uv/native_helpers" require "dependabot/uv/name_normaliser" module Dependabot module Uv class FileUpdater class RequirementReplacer PACKAGE_NOT_FOUND_ERROR = "PackageNotFoundError" CERTIFICATE_VERIFY_FAILED = /CERTIFICATE_VERIFY_FAILED/ def initialize(content:, dependency_name:, old_requirement:, new_requirement:, new_hash_version: nil, index_urls: nil) @content = content @dependency_name = normalise(dependency_name) @old_requirement = old_requirement @new_requirement = new_requirement @new_hash_version = new_hash_version @index_urls = index_urls end def updated_content updated_content = content.gsub(original_declaration_replacement_regex) do |mtch| # If the "declaration" is setting an option (e.g., no-binary) # ignore it, since it isn't actually a declaration next mtch if Regexp.last_match&.pre_match&.match?(/--.*\z/) updated_dependency_declaration_string end raise "Expected content to change!" if old_requirement != new_requirement && content == updated_content updated_content end private attr_reader :content attr_reader :dependency_name attr_reader :old_requirement attr_reader :new_requirement attr_reader :new_hash_version def update_hashes? !new_hash_version.nil? end def updated_requirement_string new_req_string = new_requirement new_req_string = new_req_string.gsub(/,\s*/, ", ") if add_space_after_commas? if add_space_after_operators? new_req_string = new_req_string .gsub(/(#{RequirementParser::COMPARISON})\s*(?=\d)/o, '\1 ') end new_req_string end def updated_dependency_declaration_string old_req = old_requirement updated_string = if old_req original_dependency_declaration_string(old_req) .sub(RequirementParser::REQUIREMENTS, updated_requirement_string) else original_dependency_declaration_string(old_req) .sub(RequirementParser::NAME_WITH_EXTRAS) do |nm| nm + updated_requirement_string end end return updated_string unless update_hashes? && requirement_includes_hashes?(old_req) updated_string.sub( RequirementParser::HASHES, package_hashes_for( name: dependency_name, version: new_hash_version, algorithm: hash_algorithm(old_req) ).join(hash_separator(old_req)) ) end def add_space_after_commas? original_dependency_declaration_string(old_requirement) .match(RequirementParser::REQUIREMENTS) .to_s.include?(", ") end def add_space_after_operators? original_dependency_declaration_string(old_requirement) .match(RequirementParser::REQUIREMENTS) .to_s.match?(/#{RequirementParser::COMPARISON}\s+\d/o) end def original_declaration_replacement_regex original_string = original_dependency_declaration_string(old_requirement) /(?<![\-\w\.\[])#{Regexp.escape(original_string)}(?![\-\w\.])/ end def requirement_includes_hashes?(requirement) original_dependency_declaration_string(requirement) .match?(RequirementParser::HASHES) end def hash_algorithm(requirement) return unless requirement_includes_hashes?(requirement) original_dependency_declaration_string(requirement) .match(RequirementParser::HASHES) .named_captures.fetch("algorithm") end def hash_separator(requirement) return unless requirement_includes_hashes?(requirement) hash_regex = RequirementParser::HASH current_separator = original_dependency_declaration_string(requirement) .match(/#{hash_regex}((?<separator>\s*\\?\s*?)#{hash_regex})*/) .named_captures.fetch("separator") default_separator = original_dependency_declaration_string(requirement) .match(RequirementParser::HASH) .pre_match.match(/(?<separator>\s*\\?\s*?)\z/) .named_captures.fetch("separator") current_separator || default_separator end def package_hashes_for(name:, version:, algorithm:) index_urls = @index_urls || [nil] index_urls.map do |index_url| args = [name, version, algorithm] args << index_url unless index_url.nil? begin result = SharedHelpers.run_helper_subprocess( command: "pyenv exec python3 #{NativeHelpers.python_helper_path}", function: "get_dependency_hash", args: args ) rescue SharedHelpers::HelperSubprocessFailed => e requirement_error_handler(e) raise unless e.message.include?("PackageNotFoundError") next end return result.map { |h| "--hash=#{algorithm}:#{h['hash']}" } if result.is_a?(Array) end raise Dependabot::DependencyFileNotResolvable, "Unable to find hashes for package #{name}" end def original_dependency_declaration_string(old_req) matches = [] dec = if old_req.nil? regex = RequirementParser::INSTALL_REQ_WITHOUT_REQUIREMENT content.scan(regex) { matches << Regexp.last_match } matches.find { |m| normalise(m[:name]) == dependency_name } else regex = RequirementParser::INSTALL_REQ_WITH_REQUIREMENT content.scan(regex) { matches << Regexp.last_match } matches .select { |m| normalise(m[:name]) == dependency_name } .find { |m| requirements_match(m[:requirements], old_req) } end raise "Declaration not found for #{dependency_name}!" unless dec dec.to_s.strip end def normalise(name) NameNormaliser.normalise(name) end def requirements_match(req1, req2) req1&.split(",")&.map { |r| r.gsub(/\s/, "") }&.sort == req2&.split(",")&.map { |r| r.gsub(/\s/, "") }&.sort end public def requirement_error_handler(error) Dependabot.logger.warn(error.message) return unless error.message.match?(CERTIFICATE_VERIFY_FAILED) msg = "Error resolving dependency." raise DependencyFileNotResolvable, msg end end end end end