From 6794a78087991f84efc8f0a959a85c411733f3a3 Mon Sep 17 00:00:00 2001 From: Seeker Date: Wed, 2 Sep 2020 12:24:21 -0700 Subject: [PATCH] livecheck: add support for casks --- Library/Homebrew/cask/dsl.rb | 17 ++ Library/Homebrew/dev-cmd/livecheck.rb | 26 +-- Library/Homebrew/livecheck.rb | 45 ++++- Library/Homebrew/livecheck/livecheck.rb | 191 ++++++++++++------ .../Homebrew/test/livecheck/livecheck_spec.rb | 35 ++++ Library/Homebrew/test/livecheck_spec.rb | 21 ++ 6 files changed, 246 insertions(+), 89 deletions(-) diff --git a/Library/Homebrew/cask/dsl.rb b/Library/Homebrew/cask/dsl.rb index 8a529036b1..7af922f50d 100644 --- a/Library/Homebrew/cask/dsl.rb +++ b/Library/Homebrew/cask/dsl.rb @@ -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) && diff --git a/Library/Homebrew/dev-cmd/livecheck.rb b/Library/Homebrew/dev-cmd/livecheck.rb index 26af2ffb88..e2ca9e1d8b 100644 --- a/Library/Homebrew/dev-cmd/livecheck.rb +++ b/Library/Homebrew/dev-cmd/livecheck.rb @@ -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 diff --git a/Library/Homebrew/livecheck.rb b/Library/Homebrew/livecheck.rb index 651d758946..4900726ff1 100644 --- a/Library/Homebrew/livecheck.rb +++ b/Library/Homebrew/livecheck.rb @@ -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 diff --git a/Library/Homebrew/livecheck/livecheck.rb b/Library/Homebrew/livecheck/livecheck.rb index d27c6b51e4..4a7e722481 100644 --- a/Library/Homebrew/livecheck/livecheck.rb +++ b/Library/Homebrew/livecheck/livecheck.rb @@ -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 diff --git a/Library/Homebrew/test/livecheck/livecheck_spec.rb b/Library/Homebrew/test/livecheck/livecheck_spec.rb index 261896abd9..0323c1e1a8 100644 --- a/Library/Homebrew/test/livecheck/livecheck_spec.rb +++ b/Library/Homebrew/test/livecheck/livecheck_spec.rb @@ -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 diff --git a/Library/Homebrew/test/livecheck_spec.rb b/Library/Homebrew/test/livecheck_spec.rb index 84ed8df539..2352d44b50 100644 --- a/Library/Homebrew/test/livecheck_spec.rb +++ b/Library/Homebrew/test/livecheck_spec.rb @@ -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