-
-
Notifications
You must be signed in to change notification settings - Fork 10.9k
Serialize CaskStructs in the internal API
#21502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request updates the internal cask API to serialize CaskStruct instances, following the same pattern established for formulae in PR #21456. The changes extract utility methods from FormulaStruct to the Utils module so they can be shared with CaskStruct, move the cask struct generation logic into a dedicated module, and implement serialization/deserialization methods for CaskStruct.
Changes:
- Refactored deep symbol stringify/unstringify and compact_blank methods from
FormulaStructtoUtilsmodule - Added serialize/deserialize methods to
CaskStructwith artifact handling - Extracted
generate_cask_struct_hashfromAPI::Caskinto newCaskStructGeneratormodule - Updated internal cask API generation to serialize cask structs before writing to JSON
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| Library/Homebrew/utils.rb | Adds deep_stringify_symbols, deep_unstringify_symbols, and deep_compact_blank utility methods moved from FormulaStruct |
| Library/Homebrew/test/utils_spec.rb | Moves tests for the utility methods from formula_struct_spec.rb to utils_spec.rb |
| Library/Homebrew/api/formula_struct.rb | Removes utility methods that were moved to Utils and updates references to use Utils module |
| Library/Homebrew/test/api/formula_struct_spec.rb | Removes tests that were moved to utils_spec.rb |
| Library/Homebrew/api/cask_struct.rb | Adds serialize, deserialize, and deserialize_artifact_args methods for CaskStruct serialization |
| Library/Homebrew/api/cask/cask_struct_generator.rb | New module containing generate_cask_struct_hash method moved from API::Cask |
| Library/Homebrew/api/cask.rb | Removes generate_cask_struct_hash method and adds require for CaskStructGenerator |
| Library/Homebrew/cask/cask_loader.rb | Updates reference to use CaskStructGenerator module |
| Library/Homebrew/dev-cmd/generate-cask-api.rb | Updates to serialize CaskStruct instances and adds tap_git_head to JSON output |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # so it must never fail with older JSON API versions | ||
| sig { params(hash: T::Hash[String, T.untyped], bottle_tag: Utils::Bottles::Tag, ignore_types: T::Boolean).returns(CaskStruct) } | ||
| def generate_cask_struct_hash(hash, bottle_tag: Utils::Bottles.tag, ignore_types: false) | ||
| hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s) |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The bottle_tag parameter is accepted by this method but not passed to merge_variations. This differs from the FormulaStructGenerator implementation which passes it correctly. The bottle_tag parameter should be passed to ensure variations are correctly merged for the target platform.
| hash = Homebrew::API.merge_variations(hash).dup.deep_symbolize_keys.transform_keys(&:to_s) | |
| hash = Homebrew::API.merge_variations(hash, bottle_tag: bottle_tag).dup.deep_symbolize_keys.transform_keys(&:to_s) |
| # 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? |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Artifacts with nil values are assigned an empty lambda block which cannot be serialized to JSON. When the CaskStruct's serialize method is called (as done in generate-cask-api.rb), this lambda will cause JSON serialization to fail. Consider replacing the lambda with nil or filtering out the block element before serialization, similar to how FormulaStruct handles special cases with SKIP_SERIALIZATION.
| # 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? | |
| # For artifacts like postflight that can't be loaded from the API but need a block slot, | |
| # use nil for the block to keep the structure consistent and JSON-serializable. | |
| next [key, [], {}, nil] if artifact[key].nil? |
| # Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below | ||
| PREDICATES.each do |name| | ||
| hash["#{name}_present"] = hash["#{name}_args"].present? |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The deserialize method attempts to reconstruct predicate_present values by checking for corresponding _args fields, but not all predicates have _args fields. For example, :auto_updates is a boolean field (not auto_updates_args), :caveats maps to raw_caveats (not caveats_args), :conflicts maps to conflicts_with_args (not conflicts_args), :desc is a string field (not desc_args), and :homepage is a string field (not homepage_args). This will cause these predicate_present values to be incorrectly set to false (or nil.present? = false) even when the actual data is present. Special handling is needed for these predicates, similar to how FormulaStruct handles bottle, stable, and head predicates with custom logic.
| # Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below | |
| PREDICATES.each do |name| | |
| hash["#{name}_present"] = hash["#{name}_args"].present? | |
| # Compute predicate *_present flags based on their actual backing fields. | |
| PREDICATES.each do |name| | |
| source_value = | |
| case name | |
| when :auto_updates | |
| hash["auto_updates"] | |
| when :caveats | |
| hash["raw_caveats"] | |
| when :conflicts, :conflicts_with | |
| hash["conflicts_with_args"] | |
| when :desc | |
| hash["desc"] | |
| when :homepage | |
| hash["homepage"] | |
| else | |
| hash["#{name}_args"] | |
| end | |
| hash["#{name}_present"] = source_value.present? |
| sig { params(bottle_tag: ::Utils::Bottles::Tag).returns(T::Hash[String, T.untyped]) } | ||
| def serialize(bottle_tag: ::Utils::Bottles.tag) | ||
| hash = self.class.decorator.all_props.filter_map do |prop| | ||
| next if PREDICATES.any? { |predicate| prop == :"#{predicate}_present" } | ||
|
|
||
| [prop.to_s, send(prop)] | ||
| end.to_h | ||
|
|
||
| hash = ::Utils.deep_stringify_symbols(hash) | ||
| ::Utils.deep_compact_blank(hash) | ||
| end | ||
|
|
||
| sig { params(hash: T::Hash[String, T.untyped]).returns(CaskStruct) } | ||
| def self.deserialize(hash) | ||
| hash = ::Utils.deep_unstringify_symbols(hash) | ||
|
|
||
| # Items that don't follow the `hash["foo_present"] = hash["foo_args"].present?` pattern are overridden below | ||
| PREDICATES.each do |name| | ||
| hash["#{name}_present"] = hash["#{name}_args"].present? | ||
| end | ||
|
|
||
| hash["raw_artifacts"] = if (raw_artifacts = hash["raw_artifacts"]) | ||
| raw_artifacts.map { |artifact| deserialize_artifact_args(artifact) } | ||
| end | ||
|
|
||
| from_hash(hash) | ||
| end | ||
|
|
||
| # Format artifact args pairs into proper [key, args, kwargs, block] format since serialization removed blanks. | ||
| sig { | ||
| params( | ||
| args: T.any( | ||
| [Symbol], | ||
| [Symbol, T::Array[T.anything]], | ||
| [Symbol, T::Hash[Symbol, T.anything]], | ||
| [Symbol, T.proc.void], | ||
| [Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything]], | ||
| [Symbol, T::Array[T.anything], T.proc.void], | ||
| [Symbol, T::Hash[Symbol, T.anything], T.proc.void], | ||
| [Symbol, T::Array[T.anything], T::Hash[Symbol, T.anything], T.proc.void], | ||
| ), | ||
| ).returns(ArtifactArgs) | ||
| } | ||
| def self.deserialize_artifact_args(args) | ||
| args = case args | ||
| in [key] then [key, [], {}, nil] | ||
| in [key, Array => array] then [key, array, {}, nil] | ||
| in [key, Hash => hash] then [key, [], hash, nil] | ||
| in [key, Proc => block] then [key, [], {}, block] | ||
| in [key, Array => array, Hash => hash] then [key, array, hash, nil] | ||
| in [key, Array => array, Proc => block] then [key, array, {}, block] | ||
| in [key, Hash => hash, Proc => block] then [key, [], hash, block] | ||
| in [key, Array => array, Hash => hash, Proc => block] then [key, array, hash, block] | ||
| end | ||
|
|
||
| # The case above is exhaustive so args will never be nil, but sorbet cannot infer that. | ||
| T.must(args) | ||
| end |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The serialize and deserialize methods are being added without corresponding test coverage. The FormulaStruct has comprehensive tests in test/api/formula_struct_spec.rb, establishing a convention that serialize/deserialize methods should be tested. Consider adding tests similar to those in formula_struct_spec.rb to verify the serialization/deserialization logic, especially for edge cases like artifacts with empty blocks and predicates with various field types.
| # typed: strict | ||
| # frozen_string_literal: true | ||
|
|
||
| module Homebrew | ||
| module API | ||
| module Cask | ||
| # Methods for generating CaskStruct instances from API data. | ||
| module CaskStructGenerator | ||
| module_function | ||
|
|
||
| # NOTE: this will be used to load installed cask JSON files, | ||
| # so it must never fail with older JSON API versions | ||
| sig { params(hash: T::Hash[String, T.untyped], bottle_tag: Utils::Bottles::Tag, ignore_types: T::Boolean).returns(CaskStruct) } | ||
| def generate_cask_struct_hash(hash, bottle_tag: Utils::Bottles.tag, ignore_types: false) | ||
| 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"] = hash.fetch("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"].present? | ||
| hash["deprecate_present"] = hash["deprecate_args"].present? | ||
| hash["desc_present"] = hash["desc"].present? | ||
| hash["disable_present"] = hash["disable_args"].present? | ||
| hash["homepage_present"] = hash["homepage"].present? | ||
|
|
||
| CaskStruct.from_hash(hash, ignore_types:) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
Copilot
AI
Feb 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CaskStructGenerator module is being added without corresponding test coverage. The FormulaStructGenerator has comprehensive tests in test/api/formula/formula_struct_generator_spec.rb, establishing a convention that struct generators should be tested. Consider adding tests similar to those in formula_struct_generator_spec.rb to verify the generation logic, especially for complex transformations like arch dependencies, macos version handling, and artifact processing.
MikeMcQuaid
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good so far!
Follow-up to #21456
This PR updates the
/api/internal/cask.TAG.jsonAPI to contain serialized cask structs, like we did with formulae.