livecheck: add support for casks

This commit is contained in:
Seeker 2020-09-02 12:24:21 -07:00 committed by Sam Ford
parent 12d8d6d2e2
commit 6794a78087
No known key found for this signature in database
GPG Key ID: 95209E46C7FFDEFE
6 changed files with 246 additions and 89 deletions

View File

@ -3,6 +3,7 @@
require "locale"
require "lazy_object"
require "livecheck"
require "cask/artifact"
@ -81,6 +82,8 @@ module Cask
:version,
:appdir,
:discontinued?,
:livecheck,
:livecheckable?,
*ORDINARY_ARTIFACT_CLASSES.map(&:dsl_key),
*ACTIVATABLE_ARTIFACT_CLASSES.map(&:dsl_key),
*ARTIFACT_BLOCK_CLASSES.flat_map { |klass| [klass.dsl_key, klass.uninstall_dsl_key] },
@ -273,6 +276,20 @@ module Cask
set_unique_stanza(:auto_updates, auto_updates.nil?) { auto_updates }
end
def livecheck(&block)
@livecheck ||= Livecheck.new(self)
return @livecheck unless block_given?
raise CaskInvalidError.new(cask, "'livecheck' stanza may only appear once.") if @livecheckable
@livecheckable = true
@livecheck.instance_eval(&block)
end
def livecheckable?
@livecheckable == true
end
ORDINARY_ARTIFACT_CLASSES.each do |klass|
define_method(klass.dsl_key) do |*args|
if [*artifacts.map(&:class), klass].include?(Artifact::StageOnly) &&

View File

@ -54,28 +54,28 @@ module Homebrew
puts ENV["HOMEBREW_LIVECHECK_WATCHLIST"] if ENV["HOMEBREW_LIVECHECK_WATCHLIST"].present?
end
formulae_to_check = if args.tap
Tap.fetch(args.tap).formula_names.map { |name| Formula[name] }
formulae_and_casks_to_check = if args.tap
tap = Tap.fetch(args.tap)
formulae = tap.formula_names.map { |name| Formula[name] }
casks = tap.cask_tokens.map { |token| Cask::CaskLoader.load(token) }
formulae + casks
elsif args.installed?
Formula.installed
Formula.installed + Cask::Caskroom.casks
elsif args.all?
Formula
elsif (formulae_args = args.named.to_formulae) && formulae_args.present?
formulae_args
Formula.to_a + Cask::Cask.to_a
elsif args.named.present?
args.named.to_formulae_and_casks
elsif File.exist?(WATCHLIST_PATH)
begin
Pathname.new(WATCHLIST_PATH).read.lines.map do |line|
next if line.start_with?("#")
Formula[line.strip]
end.compact
names = Pathname.new(WATCHLIST_PATH).read.lines.reject { |line| line.start_with?("#") }.map(&:strip)
CLI::NamedArgs.new(*names).to_formulae_and_casks
rescue Errno::ENOENT => e
onoe e
end
end
raise UsageError, "No formulae to check." if formulae_to_check.blank?
raise UsageError, "No formulae or casks to check." if formulae_and_casks_to_check.blank?
Livecheck.livecheck_formulae(formulae_to_check, args)
Livecheck.livecheck_formulae_and_casks(formulae_and_casks_to_check, args)
end
end

View File

@ -1,20 +1,20 @@
# typed: true
# frozen_string_literal: true
# The {Livecheck} class implements the DSL methods used in a formula's
# The {Livecheck} class implements the DSL methods used in a formula's or cask's
# `livecheck` block and stores related instance variables. Most of these methods
# also return the related instance variable when no argument is provided.
#
# This information is used by the `brew livecheck` command to control its
# behavior.
class Livecheck
# A very brief description of why the formula is skipped (e.g. `No longer
# A very brief description of why the formula/cask is skipped (e.g. `No longer
# developed or maintained`).
# @return [String, nil]
attr_reader :skip_msg
def initialize(formula)
@formula = formula
def initialize(formula_or_cask)
@formula_or_cask = formula_or_cask
@regex = nil
@skip = false
@skip_msg = nil
@ -40,10 +40,10 @@ class Livecheck
# Sets the `@skip` instance variable to `true` and sets the `@skip_msg`
# instance variable if a `String` is provided. `@skip` is used to indicate
# that the formula should be skipped and the `skip_msg` very briefly describes
# why the formula is skipped (e.g. "No longer developed or maintained").
# that the formula/cask should be skipped and the `skip_msg` very briefly
# describes why it is skipped (e.g. "No longer developed or maintained").
#
# @param skip_msg [String] string describing why the formula is skipped
# @param skip_msg [String] string describing why the formula/cask is skipped
# @return [Boolean]
def skip(skip_msg = nil)
if skip_msg.is_a?(String)
@ -55,7 +55,7 @@ class Livecheck
@skip = true
end
# Should `livecheck` skip this formula?
# Should `livecheck` skip this formula/cask?
def skip?
@skip
end
@ -81,7 +81,7 @@ class Livecheck
# Sets the `@url` instance variable to the provided argument or returns the
# `@url` instance variable when no argument is provided. The argument can be
# a `String` (a URL) or a supported `Symbol` corresponding to a URL in the
# formula (e.g. `:stable`, `:homepage`, or `:head`).
# formula/cask (e.g. `:stable`, `:homepage`, or `:head`).
#
# @param val [String, Symbol] URL to check for version information
# @return [String, nil]
@ -89,10 +89,12 @@ class Livecheck
@url = case val
when nil
return @url
when :cask_url
@formula_or_cask.url
when :head, :stable
@formula.send(val).url
@formula_or_cask.send(val).url
when :homepage
@formula.homepage
@formula_or_cask.homepage
when String
val
else
@ -100,6 +102,26 @@ class Livecheck
end
end
# TODO: documentation
def version(val = nil)
@version = case val
when nil
return @version
when :before_comma
[",", :first]
when :after_comma
[",", :second]
when :before_colon
[":", :first]
when :after_colon
[":", :second]
when String
val
else
raise TypeError, "Livecheck#version expects a String or valid Symbol"
end
end
# Returns a `Hash` of all instance variable values.
# @return [Hash]
def to_hash
@ -109,6 +131,7 @@ class Livecheck
"skip_msg" => @skip_msg,
"strategy" => @strategy,
"url" => @url,
"version" => @version,
}
end
end

View File

@ -41,18 +41,18 @@ module Homebrew
rc
].freeze
# Executes the livecheck logic for each formula in the `formulae_to_check` array
# and prints the results.
# Executes the livecheck logic for each formula/cask in the
# `formulae_and_casks_to_check` array and prints the results.
# @return [nil]
def livecheck_formulae(formulae_to_check, args)
def livecheck_formulae_and_casks(formulae_and_casks_to_check, args)
# Identify any non-homebrew/core taps in use for current formulae
non_core_taps = {}
formulae_to_check.each do |f|
next if f.tap.blank?
next if f.tap.name == CoreTap.instance.name
next if non_core_taps[f.tap.name]
formulae_and_casks_to_check.each do |fc|
next if fc.tap.blank?
next if fc.tap.name == CoreTap.instance.name
next if non_core_taps[fc.tap.name]
non_core_taps[f.tap.name] = f.tap
non_core_taps[fc.tap.name] = fc.tap
end
non_core_taps = non_core_taps.sort.to_h
@ -73,10 +73,10 @@ module Homebrew
has_a_newer_upstream_version = false
if args.json? && !args.quiet? && $stderr.tty?
total_formulae = if formulae_to_check == Formula
formulae_to_check.count
total_formulae = if formulae_and_casks_to_check == Formula
formulae_and_casks_to_check.count
else
formulae_to_check.length
formulae_and_casks_to_check.length
end
Tty.with($stderr) do |stderr|
@ -92,7 +92,10 @@ module Homebrew
)
end
formulae_checked = formulae_to_check.sort.map.with_index do |formula, i|
formulae_checked = formulae_and_casks_to_check.sort_by(&:name).map.with_index do |formula_or_cask, i|
formula = formula_or_cask if formula_or_cask.is_a?(Formula)
cask = formula_or_cask if formula_or_cask.is_a?(Cask::Cask)
if args.debug? && i.positive?
puts <<~EOS
@ -101,7 +104,7 @@ module Homebrew
EOS
end
skip_result = skip_conditions(formula, args: args)
skip_result = skip_conditions(formula_or_cask, args: args)
next skip_result if skip_result != false
formula.head&.downloader&.shutup!
@ -110,17 +113,32 @@ module Homebrew
# head-only formulae. A formula with `stable` and `head` that's
# installed using `--head` will still use the `stable` version for
# comparison.
current = if formula.head_only?
formula.any_installed_version.version.commit
livecheck_version = formula_or_cask.livecheck.version
current = if livecheck_version.is_a?(String)
livecheck_version
else
formula.stable.version
version = if formula
if formula.head_only?
formula.any_installed_version.version.commit
else
formula.stable.version
end
else
Version.new(formula_or_cask.version)
end
if livecheck_version.is_a?(Array)
separator, method = livecheck_version
Version.new(version.to_s.split(separator, 2).try(method))
else
version
end
end
latest = if formula.head_only?
formula.head.downloader.fetch_last_commit
else
version_info = latest_version(formula, args: args)
latest = if formula&.stable? || cask
version_info = latest_version(formula_or_cask, args: args)
version_info[:latest] if version_info.present?
else
formula.head.downloader.fetch_last_commit
end
if latest.blank?
@ -129,14 +147,14 @@ module Homebrew
next version_info if version_info.is_a?(Hash) && version_info[:status] && version_info[:messages]
next status_hash(formula, "error", [no_versions_msg], args: args)
next status_hash(formula_or_cask, "error", [no_versions_msg], args: args)
end
if (m = latest.to_s.match(/(.*)-release$/)) && !current.to_s.match(/.*-release$/)
latest = Version.new(m[1])
end
is_outdated = if formula.head_only?
is_outdated = if formula&.head_only?
# A HEAD-only formula is considered outdated if the latest upstream
# commit hash is different than the installed version's commit hash
(current != latest)
@ -144,10 +162,9 @@ module Homebrew
(current < latest)
end
is_newer_than_upstream = formula.stable? && (current > latest)
is_newer_than_upstream = (formula&.stable? || cask) && (current > latest)
info = {
formula: formula_name(formula, args: args),
version: {
current: current.to_s,
latest: latest.to_s,
@ -155,10 +172,12 @@ module Homebrew
newer_than_upstream: is_newer_than_upstream,
},
meta: {
livecheckable: formula.livecheckable?,
livecheckable: formula_or_cask.livecheckable?,
},
}
info[:meta][:head_only] = true if formula.head_only?
info[:formula] = formula_name(formula, args: args) if formula
info[:cask] = cask_name(cask, args: args) if cask
info[:meta][:head_only] = true if formula&.head_only?
info[:meta].merge!(version_info[:meta]) if version_info.present? && version_info.key?(:meta)
next if args.newer_only? && !info[:version][:outdated]
@ -178,9 +197,9 @@ module Homebrew
if args.json?
progress&.increment
status_hash(formula, "error", [e.to_s], args: args)
status_hash(formula_or_cask, "error", [e.to_s], args: args)
elsif !args.quiet?
onoe "#{Tty.blue}#{formula_name(formula, args: args)}#{Tty.reset}: #{e}"
onoe "#{Tty.blue}#{formula_or_cask_name(formula_or_cask, args: args)}#{Tty.reset}: #{e}"
nil
end
end
@ -201,6 +220,18 @@ module Homebrew
puts JSON.generate(formulae_checked.compact)
end
def formula_or_cask_name(formula_or_cask, args:)
if formula_or_cask.is_a?(Formula)
formula_name(formula_or_cask, args: args)
else
cask_name(formula_or_cask, args: args)
end
end
def cask_name(cask, args:)
args.full_name? ? cask.full_name : cask.token
end
# Returns the fully-qualified name of a formula if the `full_name` argument is
# provided; returns the name otherwise.
# @return [String]
@ -208,18 +239,25 @@ module Homebrew
args.full_name? ? formula.full_name : formula.name
end
def status_hash(formula, status_str, messages = nil, args:)
def status_hash(formula_or_cask, status_str, messages = nil, args:)
formula = formula_or_cask if formula_or_cask.is_a?(Formula)
status_hash = {
formula: formula_name(formula, args: args),
status: status_str,
status: status_str,
}
status_hash[:messages] = messages if messages.is_a?(Array)
if formula
status_hash[:formula] = formula_name(formula, args: args)
else
status_hash[:cask] = formula_name(formula_or_cask, args: args)
end
if args.verbose?
status_hash[:meta] = {
livecheckable: formula.livecheckable?,
livecheckable: formula_or_cask.livecheckable?,
}
status_hash[:meta][:head_only] = true if formula.head_only?
status_hash[:meta][:head_only] = true if formula&.head_only?
end
status_hash
@ -228,54 +266,56 @@ module Homebrew
# If a formula has to be skipped, it prints or returns a Hash contaning the reason
# for doing so; returns false otherwise.
# @return [Hash, nil, Boolean]
def skip_conditions(formula, args:)
if formula.deprecated? && !formula.livecheckable?
def skip_conditions(formula_or_cask, args:)
formula = formula_or_cask if formula_or_cask.is_a?(Formula)
if formula&.deprecated? && !formula.livecheckable?
return status_hash(formula, "deprecated", args: args) if args.json?
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : deprecated" unless args.quiet?
return
end
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : deprecated" unless args.quiet?
return
end
if formula.disabled? && !formula.livecheckable?
if formula&.disabled? && !formula.livecheckable?
return status_hash(formula, "disabled", args: args) if args.json?
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : disabled" unless args.quiet?
return
end
if formula.versioned_formula? && !formula.livecheckable?
if formula&.versioned_formula? && !formula.livecheckable?
return status_hash(formula, "versioned", args: args) if args.json?
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : versioned" unless args.quiet?
return
end
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : versioned" unless args.quiet?
return
end
if formula.head_only? && !formula.any_version_installed?
if formula&.head_only? && !formula.any_version_installed?
head_only_msg = "HEAD only formula must be installed to be livecheckable"
return status_hash(formula, "error", [head_only_msg], args: args) if args.json?
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : #{head_only_msg}" unless args.quiet?
return
end
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : #{head_only_msg}" unless args.quiet?
return
end
is_gist = formula.stable&.url&.include?("gist.github.com")
if formula.livecheck.skip? || is_gist
skip_msg = if formula.livecheck.skip_msg.is_a?(String) &&
formula.livecheck.skip_msg.present?
formula.livecheck.skip_msg.to_s
is_gist = formula&.stable&.url&.include?("gist.github.com")
if formula_or_cask.livecheck.skip? || is_gist
skip_msg = if formula_or_cask.livecheck.skip_msg.is_a?(String) &&
formula_or_cask.livecheck.skip_msg.present?
formula_or_cask.livecheck.skip_msg.to_s
elsif is_gist
"Stable URL is a GitHub Gist"
else
""
end
return status_hash(formula, "skipped", (skip_msg.blank? ? nil : [skip_msg]), args: args) if args.json?
return status_hash(formula_or_cask, "skipped", (skip_msg.blank? ? nil : [skip_msg]), args: args) if args.json?
unless args.quiet?
puts "#{Tty.red}#{formula_name(formula, args: args)}#{Tty.reset} : skipped" \
puts "#{Tty.red}#{formula_or_cask_name(formula_or_cask, args: args)}#{Tty.reset} : skipped" \
"#{" - #{skip_msg}" if skip_msg.present?}"
end
return
return
end
false
@ -284,8 +324,8 @@ module Homebrew
# Formats and prints the livecheck result for a formula.
# @return [nil]
def print_latest_version(info, args:)
formula_s = "#{Tty.blue}#{info[:formula]}#{Tty.reset}"
formula_s += " (guessed)" if !info[:meta][:livecheckable] && args.verbose?
formula_or_cask_s = "#{Tty.blue}#{info[:formula] || info[:cask]}#{Tty.reset}"
formula_or_cask_s += " (guessed)" if !info[:meta][:livecheckable] && args.verbose?
current_s = if info[:version][:newer_than_upstream]
"#{Tty.red}#{info[:version][:current]}#{Tty.reset}"
@ -299,12 +339,12 @@ module Homebrew
info[:version][:latest]
end
puts "#{formula_s} : #{current_s} ==> #{latest_s}"
puts "#{formula_or_cask_s} : #{current_s} ==> #{latest_s}"
end
# Returns an Array containing the formula URLs that can be used by livecheck.
# @return [Array]
def checkable_urls(formula)
def checkable_formula_urls(formula)
urls = []
urls << formula.head.url if formula.head
if formula.stable
@ -316,6 +356,21 @@ module Homebrew
urls.compact
end
def checkable_cask_urls(cask)
urls = []
urls << cask.url.to_s
urls << cask.homepage if cask.homepage
urls.compact
end
def checkable_urls(formula_or_cask)
if formula_or_cask.is_a?(Formula)
checkable_formula_urls(formula_or_cask)
else
checkable_cask_urls(formula_or_cask)
end
end
# Preprocesses and returns the URL used by livecheck.
# @return [String]
def preprocess_url(url)
@ -357,20 +412,26 @@ module Homebrew
# Identifies the latest version of the formula and returns a Hash containing
# the version information. Returns nil if a latest version couldn't be found.
# @return [Hash, nil]
def latest_version(formula, args:)
has_livecheckable = formula.livecheckable?
livecheck = formula.livecheck
def latest_version(formula_or_cask, args:)
formula = formula_or_cask if formula_or_cask.is_a?(Formula)
has_livecheckable = formula_or_cask.livecheckable?
livecheck = formula_or_cask.livecheck
livecheck_regex = livecheck.regex
livecheck_strategy = livecheck.strategy
livecheck_url = livecheck.url
urls = [livecheck_url] if livecheck_url.present?
urls ||= checkable_urls(formula)
urls ||= checkable_urls(formula_or_cask)
if args.debug?
puts
puts "Formula: #{formula_name(formula, args: args)}"
puts "Head only?: true" if formula.head_only?
if formula
puts "Formula: #{formula_name(formula, args: args)}"
puts "Head only?: true" if formula.head_only?
else
puts "Cask: #{cask_name(formula_or_cask, args: args)}"
end
puts "Livecheckable?: #{has_livecheckable ? "Yes" : "No"}"
end

View File

@ -74,6 +74,24 @@ describe Homebrew::Livecheck do
end
end
let(:c) do
Cask::CaskLoader.load(+<<-RUBY)
cask "test" do
version "0.0.1,2"
url "https://brew.sh/test-0.0.1.tgz"
name "Test"
homepage "https://brew.sh"
livecheck do
url "https://formulae.brew.sh/api/formula/ruby.json"
version :before_comma
regex(/"stable":"(\d+(?:\.\d+)+)"/i)
end
end
RUBY
end
let(:args) { double("livecheck_args", full_name?: false, json?: false, quiet?: false, verbose?: true) }
describe "::formula_name" do
@ -88,6 +106,18 @@ describe Homebrew::Livecheck do
end
end
describe "::cask_name" do
it "returns the token of the cask" do
expect(livecheck.cask_name(c, args: args)).to eq("test")
end
it "returns the full name of the cask" do
allow(args).to receive(:full_name?).and_return(true)
expect(livecheck.cask_name(c, args: args)).to eq("test")
end
end
describe "::status_hash" do
it "returns a hash containing the livecheck status" do
expect(livecheck.status_hash(f, "error", ["Unable to get versions"], args: args))
@ -142,6 +172,10 @@ describe Homebrew::Livecheck do
it "returns false for a non-skippable formula" do
expect(livecheck.skip_conditions(f, args: args)).to eq(false)
end
it "returns false for a non-skippable cask" do
expect(livecheck.skip_conditions(c, args: args)).to eq(false)
end
end
describe "::checkable_urls" do
@ -150,6 +184,7 @@ describe Homebrew::Livecheck do
.to eq(
["https://github.com/Homebrew/brew.git", "https://brew.sh/test-0.0.1.tgz", "https://brew.sh"],
)
expect(livecheck.checkable_urls(c)).to eq(["https://brew.sh/test-0.0.1.tgz", "https://brew.sh"])
end
end

View File

@ -107,6 +107,26 @@ describe Livecheck do
end
end
describe "#version" do
it "returns nil if not set" do
expect(livecheckable.version).to be nil
end
it "returns value if set" do
livecheckable.version("foo")
expect(livecheckable.version).to eq("foo")
livecheckable.version(:before_comma)
expect(livecheckable.version).to eq([",", :first])
end
it "raises a TypeError if the argument isn't a String or Symbol" do
expect {
livecheckable.version(/foo/)
}.to raise_error(TypeError, "Livecheck#version expects a String or valid Symbol")
end
end
describe "#to_hash" do
it "returns a Hash of all instance variables" do
expect(livecheckable.to_hash).to eq(
@ -116,6 +136,7 @@ describe Livecheck do
"skip_msg" => nil,
"strategy" => nil,
"url" => nil,
"version" => nil,
},
)
end