Use CaskStruct for loading casks from the API

This commit is contained in:
Rylan Polster
2025-12-29 18:12:09 -05:00
parent 9eac4e228c
commit 965de14256
9 changed files with 293 additions and 145 deletions
+1
View File
@@ -6,6 +6,7 @@ require "api/cask"
require "api/formula"
require "api/internal"
require "api/formula_struct"
require "api/cask_struct"
require "base64"
require "utils/output"
+110
View File
@@ -145,6 +145,116 @@ module Homebrew
Homebrew::API.write_names_file!(all_casks.keys, "cask", regenerate:)
end
sig { params(hash: T::Hash[String, T.untyped]).returns(CaskStruct) }
def self.generate_cask_struct_hash(hash)
hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s)
hash["conflicts_with_args"] = hash["conflicts_with"]
hash["container_args"] = hash["container"].to_h do |key, value|
next [key, value.to_sym] if key == :type
[key, value]
end
hash["depends_on_args"] = hash["depends_on"].to_h do |key, value|
# Arch dependencies are encoded like `{ type: :intel, bits: 64 }`
# but `depends_on arch:` only accepts `:intel` or `:arm64`
if key == :arch
next [:arch, :intel] if value.first[:type] == "intel"
next [:arch, :arm64]
end
next [key, value] if key != :macos
dep_type = value.keys.first
if dep_type == :==
version_symbols = value[dep_type].filter_map do |version|
MacOSVersion::SYMBOLS.key(version)
end
next [key, version_symbols.presence]
end
version_symbol = value[dep_type].first
version_symbol = MacOSVersion::SYMBOLS.key(version_symbol)
version_dep = "#{dep_type} :#{version_symbol}" if version_symbol
[key, version_dep]
end.compact_blank
if (deprecate_args = hash["deprecate_args"])
deprecate_args = deprecate_args.dup
deprecate_args[:because] =
DeprecateDisable.to_reason_string_or_symbol(deprecate_args[:because], type: :cask)
hash["deprecate_args"] = deprecate_args
end
if (disable_args = hash["disable_args"])
disable_args = disable_args.dup
disable_args[:because] = DeprecateDisable.to_reason_string_or_symbol(disable_args[:because], type: :cask)
hash["disable_args"] = disable_args
end
hash["names"] = hash["name"]
hash["raw_artifacts"] = Array(hash["artifacts"]).map do |artifact|
key = artifact.keys.first
# Pass an empty block to artifacts like postflight that can't be loaded from the API,
# but need to be set to something.
next [key, [], {}, -> {}] if artifact[key].nil?
args = artifact[key]
kwargs = if args.last.is_a?(Hash)
args.pop
else
{}
end
[key, args, kwargs, nil]
end
hash["raw_caveats"] = hash["caveats"]
hash["renames"] = hash["rename"].map do |operation|
[operation[:from], operation[:to]]
end
hash["ruby_source_checksum"] = {
sha256: hash.dig("ruby_source_checksum", :sha256),
}
hash["sha256"] = :no_check if hash["sha256"] == "no_check"
hash["tap_string"] = hash["tap"]
hash["url_args"] = [hash["url"]]
hash["url_kwargs"] = hash["url_specs"].to_h do |key, value|
value = case key
when :user_agent
Utils.convert_to_string_or_symbol(value)
when :using
value.to_sym
else
value
end
[key, value]
end.compact_blank
# Should match CaskStruct::PREDICATES
hash["auto_updates_present"] = hash["auto_updates"].present?
hash["caveats_present"] = hash["caveats"].present?
hash["conflicts_present"] = hash["conflicts_with"].present?
hash["container_present"] = hash["container"].present?
hash["depends_on_present"] = !hash["depends_on_args"].empty?
hash["deprecate_present"] = hash["deprecate_args"].present?
hash["desc_present"] = hash["desc"].present?
hash["disable_present"] = hash["disable_args"].present?
CaskStruct.from_hash(hash)
end
end
end
end
+105
View File
@@ -0,0 +1,105 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module API
class CaskStruct < T::Struct
PREDICATES = [
:auto_updates,
:caveats,
:conflicts,
:container,
:depends_on,
:deprecate,
:desc,
:disable,
].freeze
ArtifactArgs = T.type_alias do
[
Symbol,
T::Array[T.anything],
T::Hash[Symbol, T.anything],
T.nilable(T.proc.void),
]
end
PREDICATES.each do |predicate_name|
present_method_name = :"#{predicate_name}_present"
predicate_method_name = :"#{predicate_name}?"
const present_method_name, T::Boolean, default: false
define_method(predicate_method_name) do
send(present_method_name)
end
end
# Changes to this struct must be mirrored in Homebrew::API::Cask.generate_cask_struct_hash
const :auto_updates, T::Boolean, default: false
const :conflicts_with_args, T::Hash[Symbol, T::Array[String]], default: {}
const :container_args, T::Hash[Symbol, T.any(Symbol, T.anything)], default: {}
const :depends_on_args, T::Hash[Symbol, T::Array[T.any(String, Symbol)]], default: {}
const :deprecate_args, T::Hash[Symbol, T.nilable(T.any(String, Symbol))], default: {}
const :desc, T.nilable(String)
const :disable_args, T::Hash[Symbol, T.nilable(T.any(String, Symbol))], default: {}
const :homepage, String
const :languages, T::Array[String], default: []
const :names, T::Array[String], default: []
const :renames, T::Array[[String, String]], default: []
const :ruby_source_checksum, T::Hash[Symbol, String]
const :ruby_source_path, String
const :sha256, T.any(String, Symbol)
const :tap_git_head, String
const :tap_string, String
const :url_args, T::Array[String], default: []
const :url_kwargs, T::Hash[Symbol, T.anything], default: {}
const :version, T.any(String, Symbol)
sig { params(appdir: T.any(Pathname, String)).returns(T::Array[ArtifactArgs]) }
def artifacts(appdir:)
deep_remove_placeholders(raw_artifacts, appdir.to_s)
end
sig { params(appdir: T.any(Pathname, String)).returns(T.nilable(String)) }
def caveats(appdir:)
deep_remove_placeholders(raw_caveats, appdir.to_s)
end
private
const :raw_artifacts, T::Array[ArtifactArgs], default: []
const :raw_caveats, T.nilable(String)
sig {
type_parameters(:U)
.params(
value: T.type_parameter(:U),
appdir: String,
)
.returns(T.type_parameter(:U))
}
def deep_remove_placeholders(value, appdir)
value = case value
when Hash
value.transform_values do |v|
deep_remove_placeholders(v, appdir)
end
when Array
value.map do |v|
deep_remove_placeholders(v, appdir)
end
when String
value.gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home)
.gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX)
.gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR)
.gsub(HOMEBREW_CASK_APPDIR_PLACEHOLDER, appdir)
else
value
end
T.cast(value, T.type_parameter(:U))
end
end
end
end
+6 -10
View File
@@ -351,18 +351,14 @@ module Cask
nil
end
def populate_from_api!(json_cask)
sig { params(cask_struct: Homebrew::API::CaskStruct).void }
def populate_from_api!(cask_struct)
raise ArgumentError, "Expected cask to be loaded from the API" unless loaded_from_api?
@languages = json_cask.fetch(:languages, [])
@tap_git_head = json_cask.fetch(:tap_git_head, "HEAD")
@ruby_source_path = json_cask[:ruby_source_path]
# TODO: Clean this up when we deprecate the current JSON API and move to the internal JSON v3.
ruby_source_sha256 = json_cask.dig(:ruby_source_checksum, :sha256)
ruby_source_sha256 ||= json_cask[:ruby_source_sha256]
@ruby_source_checksum = { sha256: ruby_source_sha256 }
@languages = cask_struct.languages
@tap_git_head = cask_struct.tap_git_head
@ruby_source_path = cask_struct.ruby_source_path
@ruby_source_checksum = cask_struct.ruby_source_checksum
end
# @api public
+26 -130
View File
@@ -343,6 +343,8 @@ module Cask
Homebrew::API::Cask.all_casks.fetch(token)
end
cask_struct = Homebrew::API::Cask.generate_cask_struct_hash(json_cask)
cask_options = {
loaded_from_api: true,
api_source: json_cask,
@@ -350,157 +352,51 @@ module Cask
source: JSON.pretty_generate(json_cask),
config:,
loader: self,
tap: Tap.fetch(cask_struct.tap_string),
}
json_cask = Homebrew::API.merge_variations(json_cask).deep_symbolize_keys.freeze
cask_options[:tap] = Tap.fetch(json_cask[:tap]) if json_cask[:tap].to_s.include?("/")
user_agent = json_cask.dig(:url_specs, :user_agent)
json_cask[:url_specs][:user_agent] = user_agent[1..].to_sym if user_agent && user_agent[0] == ":"
if (using = json_cask.dig(:url_specs, :using))
json_cask[:url_specs][:using] = using.to_sym
end
api_cask = Cask.new(token, **cask_options) do
version json_cask[:version]
version cask_struct.version
sha256 cask_struct.sha256
if json_cask[:sha256] == "no_check"
sha256 :no_check
else
sha256 json_cask[:sha256]
end
url json_cask[:url], **json_cask.fetch(:url_specs, {}) if json_cask[:url].present?
json_cask[:name]&.each do |cask_name|
url(*cask_struct.url_args, **cask_struct.url_kwargs)
cask_struct.names.each do |cask_name|
name cask_name
end
desc json_cask[:desc]
homepage json_cask[:homepage]
desc cask_struct.desc if cask_struct.desc?
homepage cask_struct.homepage
if (date = json_cask[:deprecation_date].presence)
because = DeprecateDisable.to_reason_string_or_symbol json_cask[:deprecation_reason], type: :cask
deprecate! date:, because:
deprecate!(**cask_struct.deprecate_args) if cask_struct.deprecate?
disable!(**cask_struct.disable_args) if cask_struct.disable?
auto_updates cask_struct.auto_updates if cask_struct.auto_updates?
conflicts_with(**cask_struct.conflicts_with_args) if cask_struct.conflicts?
cask_struct.renames.each do |from, to|
rename from, to
end
if (date = json_cask[:disable_date].presence)
disable_reason = json_cask[:disable_reason].presence || json_cask[:deprecation_reason]
because = DeprecateDisable.to_reason_string_or_symbol disable_reason, type: :cask
disable! date:, because:
end
auto_updates json_cask[:auto_updates] unless json_cask[:auto_updates].nil?
conflicts_with(**json_cask[:conflicts_with]) if json_cask[:conflicts_with].present?
if json_cask[:rename].present?
json_cask[:rename].each do |rename_operation|
rename rename_operation.fetch(:from), rename_operation.fetch(:to)
end
end
if json_cask[:depends_on].present?
dep_hash = json_cask[:depends_on].to_h do |dep_key, dep_value|
# Arch dependencies are encoded like `{ type: :intel, bits: 64 }`
# but `depends_on arch:` only accepts `:intel` or `:arm64`
if dep_key == :arch
next [:arch, :intel] if dep_value.first[:type] == "intel"
next [:arch, :arm64]
end
next [dep_key, dep_value] if dep_key != :macos
dep_type = dep_value.keys.first
if dep_type == :==
version_symbols = dep_value[dep_type].filter_map do |version|
MacOSVersion::SYMBOLS.key(version)
end
next [dep_key, version_symbols.presence]
end
version_symbol = dep_value[dep_type].first
version_symbol = MacOSVersion::SYMBOLS.key(version_symbol)
version_dep = "#{dep_type} :#{version_symbol}" if version_symbol
[dep_key, version_dep]
end.compact
if cask_struct.depends_on?
args = cask_struct.depends_on_args
begin
depends_on(**dep_hash)
depends_on(**args)
rescue MacOSVersion::Error => e
odebug "Ignored invalid macOS version dependency in cask '#{token}': #{dep_hash.inspect} (#{e.message})"
odebug "Ignored invalid macOS version dependency in cask '#{token}': #{args.inspect} (#{e.message})"
nil
end
end
if json_cask[:container].present?
container_hash = json_cask[:container].to_h do |container_key, container_value|
next [container_key, container_value] if container_key != :type
container(**cask_struct.container_args) if cask_struct.container?
[container_key, container_value.to_sym]
end
container(**container_hash)
cask_struct.artifacts(appdir:).each do |key, args, kwargs, block|
send(key, *args, **kwargs, &block)
end
json_cask[:artifacts]&.each do |artifact|
# convert generic string replacements into actual ones
artifact = cask.loader.from_h_gsubs(artifact, appdir)
key = artifact.keys.first
if artifact[key].nil?
# for artifacts with blocks that can't be loaded from the API
send(key) {} # empty on purpose
else
args = artifact[key]
kwargs = if args.last.is_a?(Hash)
args.pop
else
{}
end
send(key, *args, **kwargs)
end
end
if json_cask[:caveats].present?
# convert generic string replacements into actual ones
caveats cask.loader.from_h_string_gsubs(json_cask[:caveats], appdir)
end
caveats cask_struct.caveats(appdir:) if cask_struct.caveats?
end
api_cask.populate_from_api!(json_cask)
api_cask.populate_from_api!(cask_struct)
api_cask
end
def from_h_string_gsubs(string, appdir)
string.to_s
.gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home)
.gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX)
.gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR)
.gsub(HOMEBREW_CASK_APPDIR_PLACEHOLDER, appdir)
end
def from_h_array_gsubs(array, appdir)
array.to_a.map do |value|
from_h_gsubs(value, appdir)
end
end
def from_h_hash_gsubs(hash, appdir)
hash.to_h.transform_values do |value|
from_h_gsubs(value, appdir)
end
end
def from_h_gsubs(value, appdir)
return value if value.blank?
case value
when Hash
from_h_hash_gsubs(value, appdir)
when Array
from_h_array_gsubs(value, appdir)
when String
from_h_string_gsubs(value, appdir)
else
value
end
end
end
# Loader which tries loading casks from tap paths, failing
@@ -0,0 +1,32 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Homebrew::API::CaskStruct`.
# Please instead update this file by running `bin/tapioca dsl Homebrew::API::CaskStruct`.
class Homebrew::API::CaskStruct
sig { returns(T::Boolean) }
def auto_updates?; end
sig { returns(T::Boolean) }
def caveats?; end
sig { returns(T::Boolean) }
def conflicts?; end
sig { returns(T::Boolean) }
def container?; end
sig { returns(T::Boolean) }
def depends_on?; end
sig { returns(T::Boolean) }
def deprecate?; end
sig { returns(T::Boolean) }
def desc?; end
sig { returns(T::Boolean) }
def disable?; end
end
@@ -3,6 +3,7 @@
require_relative "../../../global"
require "api/formula_struct"
require "api/cask_struct"
module Tapioca
module Compilers
@@ -10,12 +11,12 @@ module Tapioca
ConstantType = type_member { { fixed: T.class_of(T::Struct) } }
sig { override.returns(T::Enumerable[T::Module[T.anything]]) }
def self.gather_constants = [::Homebrew::API::FormulaStruct]
def self.gather_constants = [::Homebrew::API::FormulaStruct, ::Homebrew::API::CaskStruct]
sig { override.void }
def decorate
root.create_class(T.must(constant.name)) do |klass|
::Homebrew::API::FormulaStruct::PREDICATES.each do |predicate_name|
constant.const_get(:PREDICATES).each do |predicate_name|
klass.create_method("#{predicate_name}?", return_type: "T::Boolean")
end
end
@@ -6,6 +6,8 @@ RSpec.describe Cask::CaskLoader::FromAPILoader, :cask do
let(:cask_from_source) { Cask::CaskLoader.load(local_token) }
let(:cask_json) do
hash = cask_from_source.to_hash_with_variations
# This value will always be present in the json API, but is skipped in tests
hash["tap_git_head"] = "abcdef1234567890abcdef1234567890abcdef12"
json = JSON.pretty_generate(hash)
JSON.parse(json)
end
@@ -1,7 +1,7 @@
{
"token": "caffeine",
"full_token": "caffeine",
"tap": null,
"tap": "homebrew/cask",
"name": [
],
@@ -31,9 +31,14 @@
},
"conflicts_with": null,
"container": null,
"rename": [],
"auto_updates": null,
"tap_git_head": null,
"tap_git_head": "abcdef1234567890abcdef1234567890abcdef12",
"languages": [
]
],
"ruby_source_path": "Casks/caffeine.rb",
"ruby_source_checksum": {
"sha256": "d3c19b564ee5a17f22191599ad795a6cc9c4758d0e1269f2d13207155b378dea"
}
}