livecheck: allow inheriting from a formula/cask

This commit is contained in:
Sam Ford 2021-07-19 11:21:29 -04:00
parent bdc3391a13
commit ddde0f7589
No known key found for this signature in database
GPG Key ID: 95209E46C7FFDEFE
7 changed files with 382 additions and 20 deletions

View File

@ -18,6 +18,8 @@ class Livecheck
def initialize(formula_or_cask) def initialize(formula_or_cask)
@formula_or_cask = formula_or_cask @formula_or_cask = formula_or_cask
@referenced_cask_name = nil
@referenced_formula_name = nil
@regex = nil @regex = nil
@skip = false @skip = false
@skip_msg = nil @skip_msg = nil
@ -25,6 +27,42 @@ class Livecheck
@url = nil @url = nil
end end
# Sets the `@referenced_cask_name` instance variable to the provided `String`
# or returns the `@referenced_cask_name` instance variable when no argument
# is provided. Inherited livecheck values from the referenced cask
# (e.g. regex) can be overridden in the livecheck block.
#
# @param cask_name [String] name of cask to inherit livecheck info from
# @return [String, nil]
def cask(cask_name = nil)
case cask_name
when nil
@referenced_cask_name
when String
@referenced_cask_name = cask_name
else
raise TypeError, "Livecheck#cask expects a String"
end
end
# Sets the `@referenced_formula_name` instance variable to the provided
# `String` or returns the `@referenced_formula_name` instance variable when
# no argument is provided. Inherited livecheck values from the referenced
# formula (e.g. regex) can be overridden in the livecheck block.
#
# @param formula_name [String] name of formula to inherit livecheck info from
# @return [String, nil]
def formula(formula_name = nil)
case formula_name
when nil
@referenced_formula_name
when String
@referenced_formula_name = formula_name
else
raise TypeError, "Livecheck#formula expects a String"
end
end
# Sets the `@regex` instance variable to the provided `Regexp` or returns the # Sets the `@regex` instance variable to the provided `Regexp` or returns the
# `@regex` instance variable when no argument is provided. # `@regex` instance variable when no argument is provided.
# #
@ -109,6 +147,8 @@ class Livecheck
# @return [Hash] # @return [Hash]
def to_hash def to_hash
{ {
"cask" => @referenced_cask_name,
"formula" => @referenced_formula_name,
"regex" => @regex, "regex" => @regex,
"skip" => @skip, "skip" => @skip,
"skip_msg" => @skip_msg, "skip_msg" => @skip_msg,

View File

@ -9,6 +9,8 @@ require "ruby-progressbar"
require "uri" require "uri"
module Homebrew module Homebrew
# rubocop:disable Metrics/ModuleLength
# The {Livecheck} module consists of methods used by the `brew livecheck` # The {Livecheck} module consists of methods used by the `brew livecheck`
# command. These methods print the requested livecheck information # command. These methods print the requested livecheck information
# for formulae. # for formulae.
@ -82,6 +84,74 @@ module Homebrew
end end
end end
# Resolve formula/cask references in `livecheck` blocks to a final formula
# or cask.
sig {
params(
formula_or_cask: T.any(Formula, Cask::Cask),
first_formula_or_cask: T.any(Formula, Cask::Cask),
references: T::Array[T.any(Formula, Cask::Cask)],
full_name: T::Boolean,
debug: T::Boolean,
).returns(T.nilable(T::Array[T.untyped]))
}
def resolve_livecheck_reference(
formula_or_cask,
first_formula_or_cask = formula_or_cask,
references = [],
full_name: false,
debug: false
)
# Check the livecheck block for a formula or cask reference
livecheck = formula_or_cask.livecheck
livecheck_formula = livecheck.formula
livecheck_cask = livecheck.cask
return [nil, references] if livecheck_formula.blank? && livecheck_cask.blank?
# Load the referenced formula or cask
referenced_formula_or_cask = if livecheck_formula
Formulary.factory(livecheck_formula)
elsif livecheck_cask
Cask::CaskLoader.load(livecheck_cask)
end
# Error if a `livecheck` block references a formula/cask that was already
# referenced (or itself)
if referenced_formula_or_cask == first_formula_or_cask ||
referenced_formula_or_cask == formula_or_cask ||
references.include?(referenced_formula_or_cask)
if debug
# Print the chain of references for debugging
puts "Reference Chain:"
puts formula_or_cask_name(first_formula_or_cask, full_name: full_name)
references << referenced_formula_or_cask
references.each do |ref_formula_or_cask|
puts formula_or_cask_name(ref_formula_or_cask, full_name: full_name)
end
end
raise "Circular formula/cask reference encountered"
end
references << referenced_formula_or_cask
# Check the referenced formula/cask for a reference
next_referenced_formula_or_cask, next_references = resolve_livecheck_reference(
referenced_formula_or_cask,
first_formula_or_cask,
references,
full_name: full_name,
debug: debug,
)
# Returning references along with the final referenced formula/cask
# allows us to print the chain of references in the debug output
[
next_referenced_formula_or_cask || referenced_formula_or_cask,
next_references,
]
end
# Executes the livecheck logic for each formula/cask in the # Executes the livecheck logic for each formula/cask in the
# `formulae_and_casks_to_check` array and prints the results. # `formulae_and_casks_to_check` array and prints the results.
sig { sig {
@ -139,6 +209,7 @@ module Homebrew
) )
end end
# rubocop:disable Metrics/BlockLength
formulae_checked = formulae_and_casks_to_check.map.with_index do |formula_or_cask, i| formulae_checked = formulae_and_casks_to_check.map.with_index do |formula_or_cask, i|
formula = formula_or_cask if formula_or_cask.is_a?(Formula) formula = formula_or_cask if formula_or_cask.is_a?(Formula)
cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask) cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask)
@ -146,6 +217,9 @@ module Homebrew
use_full_name = full_name || ambiguous_names.include?(formula_or_cask) use_full_name = full_name || ambiguous_names.include?(formula_or_cask)
name = formula_or_cask_name(formula_or_cask, full_name: use_full_name) name = formula_or_cask_name(formula_or_cask, full_name: use_full_name)
referenced_formula_or_cask, livecheck_references =
resolve_livecheck_reference(formula_or_cask, full_name: use_full_name, debug: debug)
if debug && i.positive? if debug && i.positive?
puts <<~EOS puts <<~EOS
@ -156,7 +230,17 @@ module Homebrew
puts puts
end end
skip_info = SkipConditions.skip_information(formula_or_cask, full_name: use_full_name, verbose: verbose) # Check skip conditions for a referenced formula/cask
if referenced_formula_or_cask
skip_info = SkipConditions.referenced_skip_information(
referenced_formula_or_cask,
name,
full_name: use_full_name,
verbose: verbose,
)
end
skip_info ||= SkipConditions.skip_information(formula_or_cask, full_name: use_full_name, verbose: verbose)
if skip_info.present? if skip_info.present?
next skip_info if json next skip_info if json
@ -188,7 +272,9 @@ module Homebrew
else else
version_info = latest_version( version_info = latest_version(
formula_or_cask, formula_or_cask,
json: json, full_name: use_full_name, verbose: verbose, debug: debug, referenced_formula_or_cask: referenced_formula_or_cask,
livecheck_references: livecheck_references,
json: json, full_name: use_full_name, verbose: verbose, debug: debug
) )
version_info[:latest] if version_info.present? version_info[:latest] if version_info.present?
end end
@ -262,6 +348,7 @@ module Homebrew
nil nil
end end
end end
# rubocop:enable Metrics/BlockLength
puts "No newer upstream versions." if newer_only && !has_a_newer_upstream_version && !debug && !json puts "No newer upstream versions." if newer_only && !has_a_newer_upstream_version && !debug && !json
@ -444,27 +531,40 @@ module Homebrew
# the version information. Returns nil if a latest version couldn't be found. # the version information. Returns nil if a latest version couldn't be found.
sig { sig {
params( params(
formula_or_cask: T.any(Formula, Cask::Cask), formula_or_cask: T.any(Formula, Cask::Cask),
json: T::Boolean, referenced_formula_or_cask: T.nilable(T.any(Formula, Cask::Cask)),
full_name: T::Boolean, livecheck_references: T::Array[T.any(Formula, Cask::Cask)],
verbose: T::Boolean, json: T::Boolean,
debug: T::Boolean, full_name: T::Boolean,
verbose: T::Boolean,
debug: T::Boolean,
).returns(T.nilable(Hash)) ).returns(T.nilable(Hash))
} }
def latest_version(formula_or_cask, json: false, full_name: false, verbose: false, debug: false) def latest_version(
formula_or_cask,
referenced_formula_or_cask: nil,
livecheck_references: [],
json: false, full_name: false, verbose: false, debug: false
)
formula = formula_or_cask if formula_or_cask.is_a?(Formula) formula = formula_or_cask if formula_or_cask.is_a?(Formula)
cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask) cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask)
has_livecheckable = formula_or_cask.livecheckable? has_livecheckable = formula_or_cask.livecheckable?
livecheck = formula_or_cask.livecheck livecheck = formula_or_cask.livecheck
livecheck_url = livecheck.url referenced_livecheck = referenced_formula_or_cask&.livecheck
livecheck_regex = livecheck.regex
livecheck_strategy = livecheck.strategy
livecheck_url_string = livecheck_url_to_string(livecheck_url, formula_or_cask) livecheck_url = livecheck.url || referenced_livecheck&.url
livecheck_regex = livecheck.regex || referenced_livecheck&.regex
livecheck_strategy = livecheck.strategy || referenced_livecheck&.strategy
livecheck_strategy_block = livecheck.strategy_block || referenced_livecheck&.strategy_block
livecheck_url_string = livecheck_url_to_string(
livecheck_url,
referenced_formula_or_cask || formula_or_cask,
)
urls = [livecheck_url_string] if livecheck_url_string urls = [livecheck_url_string] if livecheck_url_string
urls ||= checkable_urls(formula_or_cask) urls ||= checkable_urls(referenced_formula_or_cask || formula_or_cask)
if debug if debug
if formula if formula
@ -474,8 +574,18 @@ module Homebrew
puts "Cask: #{cask_name(formula_or_cask, full_name: full_name)}" puts "Cask: #{cask_name(formula_or_cask, full_name: full_name)}"
end end
puts "Livecheckable?: #{has_livecheckable ? "Yes" : "No"}" puts "Livecheckable?: #{has_livecheckable ? "Yes" : "No"}"
livecheck_references.each do |ref_formula_or_cask|
case ref_formula_or_cask
when Formula
puts "Formula Ref: #{formula_name(ref_formula_or_cask, full_name: full_name)}"
when Cask::Cask
puts "Cask Ref: #{cask_name(ref_formula_or_cask, full_name: full_name)}"
end
end
end end
# rubocop:disable Metrics/BlockLength
urls.each_with_index do |original_url, i| urls.each_with_index do |original_url, i|
if debug if debug
puts puts
@ -499,7 +609,7 @@ module Homebrew
livecheck_strategy: livecheck_strategy, livecheck_strategy: livecheck_strategy,
url_provided: livecheck_url.present?, url_provided: livecheck_url.present?,
regex_provided: livecheck_regex.present?, regex_provided: livecheck_regex.present?,
block_provided: livecheck.strategy_block.present?, block_provided: livecheck_strategy_block.present?,
) )
strategy = Strategy.from_symbol(livecheck_strategy) || strategies.first strategy = Strategy.from_symbol(livecheck_strategy) || strategies.first
strategy_name = livecheck_strategy_names[strategy] strategy_name = livecheck_strategy_names[strategy]
@ -514,7 +624,7 @@ module Homebrew
end end
if livecheck_strategy.present? if livecheck_strategy.present?
if livecheck_strategy == :page_match && (livecheck_regex.blank? && livecheck.strategy_block.blank?) if livecheck_strategy == :page_match && (livecheck_regex.blank? && livecheck_strategy_block.blank?)
odebug "#{strategy_name} strategy requires a regex or block" odebug "#{strategy_name} strategy requires a regex or block"
next next
elsif livecheck_url.blank? elsif livecheck_url.blank?
@ -529,7 +639,7 @@ module Homebrew
next if strategy.blank? next if strategy.blank?
strategy_data = begin strategy_data = begin
strategy.find_versions(url, livecheck_regex, cask: cask, &livecheck.strategy_block) strategy.find_versions(url, livecheck_regex, cask: cask, &livecheck_strategy_block)
rescue ArgumentError => e rescue ArgumentError => e
raise unless e.message.include?("unknown keyword: cask") raise unless e.message.include?("unknown keyword: cask")
@ -584,6 +694,17 @@ module Homebrew
if json && verbose if json && verbose
version_info[:meta] = {} version_info[:meta] = {}
if livecheck_references.present?
version_info[:meta][:references] = livecheck_references.map do |ref_formula_or_cask|
case ref_formula_or_cask
when Formula
{ formula: formula_name(ref_formula_or_cask, full_name: full_name) }
when Cask::Cask
{ cask: cask_name(ref_formula_or_cask, full_name: full_name) }
end
end
end
version_info[:meta][:url] = {} version_info[:meta][:url] = {}
version_info[:meta][:url][:symbol] = livecheck_url if livecheck_url.is_a?(Symbol) && livecheck_url_string version_info[:meta][:url][:symbol] = livecheck_url if livecheck_url.is_a?(Symbol) && livecheck_url_string
version_info[:meta][:url][:original] = original_url version_info[:meta][:url][:original] = original_url
@ -599,8 +720,10 @@ module Homebrew
return version_info return version_info
end end
# rubocop:enable Metrics/BlockLength
nil nil
end end
end end
# rubocop:enable Metrics/ModuleLength
end end

View File

@ -201,6 +201,54 @@ module Homebrew
{} {}
end end
# Skip conditions for formulae/casks referenced in a `livecheck` block
# are treated differently than normal. We only respect certain skip
# conditions (returning the related hash) and others are treated as
# errors.
sig {
params(
livecheck_formula_or_cask: T.any(Formula, Cask::Cask),
original_formula_or_cask_name: String,
full_name: T::Boolean,
verbose: T::Boolean,
).returns(T.nilable(Hash))
}
def referenced_skip_information(
livecheck_formula_or_cask,
original_formula_or_cask_name,
full_name: false,
verbose: false
)
skip_info = SkipConditions.skip_information(
livecheck_formula_or_cask,
full_name: full_name,
verbose: verbose,
)
return if skip_info.blank?
referenced_name = Livecheck.formula_or_cask_name(livecheck_formula_or_cask, full_name: full_name)
referenced_type = case livecheck_formula_or_cask
when Formula
:formula
when Cask::Cask
:cask
end
if skip_info[:status] != "error" &&
!(skip_info[:status] == "skipped" && livecheck_formula_or_cask.livecheck.skip?)
error_msg_end = if skip_info[:status] == "skipped"
"automatically skipped"
else
"skipped as #{skip_info[:status]}"
end
raise "Referenced #{referenced_type} (#{referenced_name}) is #{error_msg_end}"
end
skip_info[referenced_type] = original_formula_or_cask_name
skip_info
end
# Prints default livecheck output in relation to skip conditions. # Prints default livecheck output in relation to skip conditions.
sig { params(skip_hash: Hash).void } sig { params(skip_hash: Hash).void }
def print_skip_information(skip_hash) def print_skip_information(skip_hash)

View File

@ -50,6 +50,10 @@ module RuboCop
skip = find_every_method_call_by_name(livecheck_node, :skip).first skip = find_every_method_call_by_name(livecheck_node, :skip).first
return if skip.present? return if skip.present?
formula_node = find_every_method_call_by_name(livecheck_node, :formula).first
cask_node = find_every_method_call_by_name(livecheck_node, :cask).first
return if formula_node.present? || cask_node.present?
livecheck_url = find_every_method_call_by_name(livecheck_node, :url).first livecheck_url = find_every_method_call_by_name(livecheck_node, :url).first
return if livecheck_url.present? return if livecheck_url.present?

View File

@ -44,6 +44,15 @@ describe Homebrew::Livecheck do
RUBY RUBY
end end
describe "::resolve_livecheck_reference" do
context "when a formula/cask has a livecheck block without formula/cask methods" do
it "returns [nil, []]" do
expect(livecheck.resolve_livecheck_reference(f)).to eq([nil, []])
expect(livecheck.resolve_livecheck_reference(c)).to eq([nil, []])
end
end
end
describe "::formula_name" do describe "::formula_name" do
it "returns the name of the formula" do it "returns the name of the formula" do
expect(livecheck.formula_name(f)).to eq("test") expect(livecheck.formula_name(f)).to eq("test")

View File

@ -264,7 +264,7 @@ describe Homebrew::Livecheck::SkipConditions do
} }
end end
describe "::skip_conditions" do describe "::skip_information" do
context "when a formula without a livecheckable is deprecated" do context "when a formula without a livecheckable is deprecated" do
it "skips" do it "skips" do
expect(skip_conditions.skip_information(formulae[:deprecated])) expect(skip_conditions.skip_information(formulae[:deprecated]))
@ -293,21 +293,21 @@ describe Homebrew::Livecheck::SkipConditions do
end end
end end
context "when a formula has a GitHub Gist stable URL" do context "when a formula without a livecheckable has a GitHub Gist stable URL" do
it "skips" do it "skips" do
expect(skip_conditions.skip_information(formulae[:gist])) expect(skip_conditions.skip_information(formulae[:gist]))
.to eq(status_hashes[:formula][:gist]) .to eq(status_hashes[:formula][:gist])
end end
end end
context "when a formula has a Google Code Archive stable URL" do context "when a formula without a livecheckable has a Google Code Archive stable URL" do
it "skips" do it "skips" do
expect(skip_conditions.skip_information(formulae[:google_code_archive])) expect(skip_conditions.skip_information(formulae[:google_code_archive]))
.to eq(status_hashes[:formula][:google_code_archive]) .to eq(status_hashes[:formula][:google_code_archive])
end end
end end
context "when a formula has an Internet Archive stable URL" do context "when a formula without a livecheckable has an Internet Archive stable URL" do
it "skips" do it "skips" do
expect(skip_conditions.skip_information(formulae[:internet_archive])) expect(skip_conditions.skip_information(formulae[:internet_archive]))
.to eq(status_hashes[:formula][:internet_archive]) .to eq(status_hashes[:formula][:internet_archive])
@ -364,6 +364,108 @@ describe Homebrew::Livecheck::SkipConditions do
end end
end end
describe "::referenced_skip_information" do
let(:original_name) { "original" }
context "when a formula without a livecheckable is deprecated" do
it "errors" do
expect { skip_conditions.referenced_skip_information(formulae[:deprecated], original_name) }
.to raise_error(RuntimeError, "Referenced formula (test_deprecated) is skipped as deprecated")
end
end
context "when a formula without a livecheckable is disabled" do
it "errors" do
expect { skip_conditions.referenced_skip_information(formulae[:disabled], original_name) }
.to raise_error(RuntimeError, "Referenced formula (test_disabled) is skipped as disabled")
end
end
context "when a formula without a livecheckable is versioned" do
it "errors" do
expect { skip_conditions.referenced_skip_information(formulae[:versioned], original_name) }
.to raise_error(RuntimeError, "Referenced formula (test@0.0.1) is skipped as versioned")
end
end
context "when a formula is HEAD-only and not installed" do
it "skips " do
expect(skip_conditions.referenced_skip_information(formulae[:head_only], original_name))
.to eq(status_hashes[:formula][:head_only].merge({ formula: original_name }))
end
end
context "when a formula without a livecheckable has a GitHub Gist stable URL" do
it "errors" do
expect { skip_conditions.referenced_skip_information(formulae[:gist], original_name) }
.to raise_error(RuntimeError, "Referenced formula (test_gist) is automatically skipped")
end
end
context "when a formula without a livecheckable has a Google Code Archive stable URL" do
it "errors" do
expect { skip_conditions.referenced_skip_information(formulae[:google_code_archive], original_name) }
.to raise_error(RuntimeError, "Referenced formula (test_google_code_archive) is automatically skipped")
end
end
context "when a formula without a livecheckable has an Internet Archive stable URL" do
it "errors" do
expect { skip_conditions.referenced_skip_information(formulae[:internet_archive], original_name) }
.to raise_error(RuntimeError, "Referenced formula (test_internet_archive) is automatically skipped")
end
end
context "when a formula has a `livecheck` block containing `skip`" do
it "skips" do
expect(skip_conditions.referenced_skip_information(formulae[:skip], original_name))
.to eq(status_hashes[:formula][:skip].merge({ formula: original_name }))
expect(skip_conditions.referenced_skip_information(formulae[:skip_with_message], original_name))
.to eq(status_hashes[:formula][:skip_with_message].merge({ formula: original_name }))
end
end
context "when a cask without a livecheckable is discontinued" do
it "errors" do
expect { skip_conditions.referenced_skip_information(casks[:discontinued], original_name) }
.to raise_error(RuntimeError, "Referenced cask (test_discontinued) is skipped as discontinued")
end
end
context "when a cask without a livecheckable has `version :latest`" do
it "errors" do
expect { skip_conditions.referenced_skip_information(casks[:latest], original_name) }
.to raise_error(RuntimeError, "Referenced cask (test_latest) is skipped as latest")
end
end
context "when a cask without a livecheckable has an unversioned URL" do
it "errors" do
expect { skip_conditions.referenced_skip_information(casks[:unversioned], original_name) }
.to raise_error(RuntimeError, "Referenced cask (test_unversioned) is skipped as unversioned")
end
end
context "when a cask has a `livecheck` block containing `skip`" do
it "skips" do
expect(skip_conditions.referenced_skip_information(casks[:skip], original_name))
.to eq(status_hashes[:cask][:skip].merge({ cask: original_name }))
expect(skip_conditions.referenced_skip_information(casks[:skip_with_message], original_name))
.to eq(status_hashes[:cask][:skip_with_message].merge({ cask: original_name }))
end
end
it "returns an empty hash for a non-skippable formula" do
expect(skip_conditions.referenced_skip_information(formulae[:basic], original_name)).to eq(nil)
end
it "returns an empty hash for a non-skippable cask" do
expect(skip_conditions.referenced_skip_information(casks[:basic], original_name)).to eq(nil)
end
end
describe "::print_skip_information" do describe "::print_skip_information" do
context "when a formula without a livecheckable is deprecated" do context "when a formula without a livecheckable is deprecated" do
it "prints skip information" do it "prints skip information" do

View File

@ -28,6 +28,40 @@ describe Livecheck do
end end
let(:livecheckable_c) { described_class.new(c) } let(:livecheckable_c) { described_class.new(c) }
describe "#formula" do
it "returns nil if not set" do
expect(livecheckable_f.formula).to be nil
end
it "returns the String if set" do
livecheckable_f.formula("other-formula")
expect(livecheckable_f.formula).to eq("other-formula")
end
it "raises a TypeError if the argument isn't a String" do
expect {
livecheckable_f.formula(123)
}.to raise_error(TypeError, "Livecheck#formula expects a String")
end
end
describe "#cask" do
it "returns nil if not set" do
expect(livecheckable_c.cask).to be nil
end
it "returns the String if set" do
livecheckable_c.cask("other-cask")
expect(livecheckable_c.cask).to eq("other-cask")
end
it "raises a TypeError if the argument isn't a String" do
expect {
livecheckable_c.cask(123)
}.to raise_error(TypeError, "Livecheck#cask expects a String")
end
end
describe "#regex" do describe "#regex" do
it "returns nil if not set" do it "returns nil if not set" do
expect(livecheckable_f.regex).to be nil expect(livecheckable_f.regex).to be nil
@ -128,6 +162,8 @@ describe Livecheck do
it "returns a Hash of all instance variables" do it "returns a Hash of all instance variables" do
expect(livecheckable_f.to_hash).to eq( expect(livecheckable_f.to_hash).to eq(
{ {
"cask" => nil,
"formula" => nil,
"regex" => nil, "regex" => nil,
"skip" => false, "skip" => false,
"skip_msg" => nil, "skip_msg" => nil,