From f89ead08c32b490430d0c884b5a957c033cdf924 Mon Sep 17 00:00:00 2001 From: Daeho Ro <40587651+daeho-ro@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:13:39 +0900 Subject: [PATCH] feat: add `update-perl-resources` dev-cmd --- .../Homebrew/dev-cmd/update-perl-resources.rb | 36 +++ .../dev_cmd/update_perl_resources.rbi | 28 +++ .../dev-cmd/update-perl-resources_spec.rb | 8 + Library/Homebrew/test/utils/cpan_spec.rb | 47 ++++ Library/Homebrew/utils/cpan.rb | 235 ++++++++++++++++++ 5 files changed, 354 insertions(+) create mode 100644 Library/Homebrew/dev-cmd/update-perl-resources.rb create mode 100644 Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/update_perl_resources.rbi create mode 100644 Library/Homebrew/test/dev-cmd/update-perl-resources_spec.rb create mode 100644 Library/Homebrew/test/utils/cpan_spec.rb create mode 100644 Library/Homebrew/utils/cpan.rb diff --git a/Library/Homebrew/dev-cmd/update-perl-resources.rb b/Library/Homebrew/dev-cmd/update-perl-resources.rb new file mode 100644 index 0000000000..266674f728 --- /dev/null +++ b/Library/Homebrew/dev-cmd/update-perl-resources.rb @@ -0,0 +1,36 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "utils/cpan" + +module Homebrew + module DevCmd + class UpdatePerlResources < AbstractCommand + cmd_args do + description <<~EOS + Update versions for CPAN resource blocks in . + EOS + switch "-p", "--print-only", + description: "Print the updated resource blocks instead of changing ." + switch "-s", "--silent", + description: "Suppress any output." + switch "--ignore-errors", + description: "Continue processing even if some resources can't be resolved." + + named_args :formula, min: 1, without_api: true + end + + sig { override.void } + def run + args.named.to_formulae.each do |formula| + CPAN.update_perl_resources! formula, + print_only: args.print_only?, + silent: args.silent?, + verbose: args.verbose?, + ignore_errors: args.ignore_errors? + end + end + end + end +end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/update_perl_resources.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/update_perl_resources.rbi new file mode 100644 index 0000000000..b5e1429044 --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/update_perl_resources.rbi @@ -0,0 +1,28 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for dynamic methods in `Homebrew::DevCmd::UpdatePerlResources`. +# Please instead update this file by running `bin/tapioca dsl Homebrew::DevCmd::UpdatePerlResources`. + + +class Homebrew::DevCmd::UpdatePerlResources + sig { returns(Homebrew::DevCmd::UpdatePerlResources::Args) } + def args; end +end + +class Homebrew::DevCmd::UpdatePerlResources::Args < Homebrew::CLI::Args + sig { returns(T::Boolean) } + def ignore_errors?; end + + sig { returns(T::Boolean) } + def p?; end + + sig { returns(T::Boolean) } + def print_only?; end + + sig { returns(T::Boolean) } + def s?; end + + sig { returns(T::Boolean) } + def silent?; end +end diff --git a/Library/Homebrew/test/dev-cmd/update-perl-resources_spec.rb b/Library/Homebrew/test/dev-cmd/update-perl-resources_spec.rb new file mode 100644 index 0000000000..cec9170c24 --- /dev/null +++ b/Library/Homebrew/test/dev-cmd/update-perl-resources_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" +require "dev-cmd/update-perl-resources" + +RSpec.describe Homebrew::DevCmd::UpdatePerlResources do + it_behaves_like "parseable arguments" +end diff --git a/Library/Homebrew/test/utils/cpan_spec.rb b/Library/Homebrew/test/utils/cpan_spec.rb new file mode 100644 index 0000000000..2270d686cf --- /dev/null +++ b/Library/Homebrew/test/utils/cpan_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "utils/cpan" + +RSpec.describe CPAN do + let(:cpan_package_url) do + "https://cpan.metacpan.org/authors/id/P/PE/PEVANS/Scalar-List-Utils-1.68.tar.gz" + end + let(:non_cpan_package_url) do + "https://github.com/example/package/archive/v1.0.0.tar.gz" + end + + describe CPAN::Package do + let(:package_from_cpan_url) { described_class.new("Scalar::Util", cpan_package_url) } + let(:package_from_non_cpan_url) { described_class.new("SomePackage", non_cpan_package_url) } + + describe "initialize" do + it "initializes resource name" do + expect(package_from_cpan_url.name).to eq "Scalar::Util" + end + + it "extracts package name from CPAN url" do + expect(package_from_cpan_url.package_name).to eq "Scalar-List-Utils" + end + + it "extracts version from CPAN url" do + expect(package_from_cpan_url.current_version).to eq "1.68" + end + end + + describe ".valid_cpan_package?" do + it "is true for CPAN URLs" do + expect(package_from_cpan_url.valid_cpan_package?).to be true + end + + it "is false for non-CPAN URLs" do + expect(package_from_non_cpan_url.valid_cpan_package?).to be false + end + end + + describe ".to_s" do + it "returns resource name" do + expect(package_from_cpan_url.to_s).to eq "Scalar::Util" + end + end + end +end diff --git a/Library/Homebrew/utils/cpan.rb b/Library/Homebrew/utils/cpan.rb new file mode 100644 index 0000000000..89529123b4 --- /dev/null +++ b/Library/Homebrew/utils/cpan.rb @@ -0,0 +1,235 @@ +# typed: strict +# frozen_string_literal: true + +require "utils/inreplace" + +# Helper functions for updating CPAN resources. +module CPAN + METACPAN_URL_PREFIX = "https://cpan.metacpan.org/authors/id/" + private_constant :METACPAN_URL_PREFIX + + # Represents a Perl package from an existing resource. + class Package + sig { params(resource_name: String, resource_url: String).void } + def initialize(resource_name, resource_url) + @cpan_info = T.let(nil, T.nilable(T::Array[String])) + @resource_name = resource_name + @resource_url = resource_url + @is_cpan_url = T.let(resource_url.start_with?(METACPAN_URL_PREFIX), T::Boolean) + end + + sig { returns(String) } + def name + @resource_name + end + + sig { returns(T.nilable(String)) } + def current_version + extract_version_from_url if @current_version.blank? + @current_version + end + + sig { returns(T.nilable(String)) } + def package_name + extract_package_name_from_url if @package_name.blank? + @package_name + end + + sig { returns(T::Boolean) } + def valid_cpan_package? + @is_cpan_url + end + + # Get latest release information from MetaCPAN API. + sig { returns(T.nilable(T::Array[String])) } + def latest_cpan_info + return @cpan_info if @cpan_info.present? + return unless valid_cpan_package? + + pname = package_name + return unless pname + + metadata_url = "https://fastapi.metacpan.org/v1/release/#{pname}" + result = Utils::Curl.curl_output(metadata_url, "--location", "--fail") + return unless result.status.success? + + begin + json = JSON.parse(result.stdout) + rescue JSON::ParserError + return + end + + download_url = json["download_url"] + return unless download_url + + checksum = get_checksum_from_cpan(download_url) + return unless checksum + + @cpan_info = [@resource_name, download_url, checksum, json["version"]] + end + + sig { returns(String) } + def to_s + @resource_name + end + + private + + sig { returns(T.nilable(String)) } + def extract_version_from_url + return unless @is_cpan_url + + match = File.basename(@resource_url).match(/^(.+)-([0-9.v]+)\.tar\.gz$/) + return unless match + + @current_version = T.let(match[2], T.nilable(String)) + end + + sig { returns(T.nilable(String)) } + def extract_package_name_from_url + return unless @is_cpan_url + + match = File.basename(@resource_url).match(/^(.+)-([0-9.v]+)\.tar\.gz$/) + return unless match + + @package_name = T.let(match[1], T.nilable(String)) + end + + # Get SHA256 checksum from CPAN CHECKSUMS file. + sig { params(download_url: String).returns(T.nilable(String)) } + def get_checksum_from_cpan(download_url) + filename = File.basename(download_url) + dir_url = File.dirname(download_url) + + checksums_url = "#{dir_url}/CHECKSUMS" + checksums_result = Utils::Curl.curl_output(checksums_url, "--location", "--fail") + + return unless checksums_result.status.success? + + checksums_content = checksums_result.stdout + file_block_pattern = /'#{Regexp.escape(filename)}'\s*=>\s*\{[^}]*'sha256'\s*=>\s*'([a-f0-9]{64})'/mi + sha256_match = checksums_content.match(file_block_pattern) + return sha256_match[1] if sha256_match + + alt_pattern = /#{Regexp.escape(filename)}.*?sha256.*?([a-f0-9]{64})/mi + alt_match = checksums_content.match(alt_pattern) + return alt_match[1] if alt_match + + nil + end + end + + # Update CPAN resources in a formula. + sig { + params( + formula: Formula, + print_only: T.nilable(T::Boolean), + silent: T.nilable(T::Boolean), + verbose: T.nilable(T::Boolean), + ignore_errors: T.nilable(T::Boolean), + ).returns(T.nilable(T::Boolean)) + } + def self.update_perl_resources!(formula, print_only: false, silent: false, verbose: false, ignore_errors: false) + cpan_resources = formula.resources.select { |resource| resource.url.start_with?(METACPAN_URL_PREFIX) } + + odie "\"#{formula.name}\" has no CPAN resources to update." if cpan_resources.empty? + + show_info = !print_only && !silent + + non_cpan_resources = formula.resources.reject { |resource| resource.url.start_with?(METACPAN_URL_PREFIX) } + ohai "Skipping #{non_cpan_resources.length} non-CPAN resources" if non_cpan_resources.any? && show_info + ohai "Found #{cpan_resources.length} CPAN resources to update" if show_info + + new_resource_blocks = "" + package_errors = "" + updated_count = 0 + + cpan_resources.each do |resource| + package = Package.new(resource.name, resource.url) + + unless package.valid_cpan_package? + if ignore_errors + package_errors += " # RESOURCE-ERROR: \"#{resource.name}\" is not a valid CPAN resource\n" + next + else + odie "\"#{resource.name}\" is not a valid CPAN resource" + end + end + + ohai "Checking \"#{resource.name}\" for updates..." if show_info + + info = package.latest_cpan_info + + unless info + if ignore_errors + package_errors += " # RESOURCE-ERROR: Unable to resolve \"#{resource.name}\"\n" + next + else + odie "Unable to resolve \"#{resource.name}\"" + end + end + + name, url, checksum, new_version = info + current_version = package.current_version + + if current_version && new_version && current_version != new_version + ohai "\"#{resource.name}\": #{current_version} -> #{new_version}" if show_info + updated_count += 1 + elsif show_info + ohai "\"#{resource.name}\": already up to date (#{current_version})" if current_version + end + + new_resource_blocks += <<-EOS + resource "#{name}" do + url "#{url}" + sha256 "#{checksum}" + end + + EOS + end + + package_errors += "\n" if package_errors.present? + resource_section = "#{package_errors}#{new_resource_blocks}" + + if print_only + puts resource_section.chomp + return true + end + + if formula.resources.all? { |resource| resource.name.start_with?("homebrew-") } + inreplace_regex = / def install/ + resource_section += " def install" + else + inreplace_regex = / + \ \ ( + (\#\ RESOURCE-ERROR:\ .*\s+)* + resource\ .*\ do\s+ + url\ .*\s+ + sha256\ .*\s+ + ((\#.*\s+)* + patch\ (.*\ )?do\s+ + url\ .*\s+ + sha256\ .*\s+ + end\s+)* + end\s+)+ + /x + resource_section += " " + end + + ohai "Updating resource blocks" unless silent + Utils::Inreplace.inreplace formula.path do |s| + if T.must(s.inreplace_string.split(/^ test do\b/, 2).first).scan(inreplace_regex).length > 1 + odie "Unable to update resource blocks for \"#{formula.name}\" automatically. Please update them manually." + end + s.sub! inreplace_regex, resource_section + end + + if package_errors.present? + ofail "Unable to resolve some dependencies. Please check #{formula.path} for RESOURCE-ERROR comments." + elsif updated_count.positive? + ohai "Updated #{updated_count} CPAN resource#{"s" if updated_count != 1}" unless silent + end + + true + end +end