diff --git a/Library/Homebrew/ast_constants.rb b/Library/Homebrew/ast_constants.rb index a2c1a5960649a..70b1ba829a8e4 100644 --- a/Library/Homebrew/ast_constants.rb +++ b/Library/Homebrew/ast_constants.rb @@ -43,6 +43,7 @@ [{ name: :cxxstdlib_check, type: :method_call }], [{ name: :link_overwrite, type: :method_call }], [{ name: :fails_with, type: :method_call }, { name: :fails_with, type: :block_call }], + [{ name: :pypi_packages, type: :method_call }], [{ name: :resource, type: :block_call }], [{ name: :patch, type: :method_call }, { name: :patch, type: :block_call }], [{ name: :needs, type: :method_call }], diff --git a/Library/Homebrew/formula.rb b/Library/Homebrew/formula.rb index b4ac1c94922c2..a8d8be768bc35 100644 --- a/Library/Homebrew/formula.rb +++ b/Library/Homebrew/formula.rb @@ -42,6 +42,7 @@ require "api" require "api_hashable" require "utils/output" +require "pypi_packages" # A formula provides instructions and metadata for Homebrew to install a piece # of software. Every Homebrew formula is a {Formula}. @@ -219,6 +220,11 @@ class Formula sig { returns(T.any(BuildOptions, Tab)) } attr_reader :build + # Information about PyPI mappings for this {Formula} is stored + # as {PypiPackages} object. + sig { returns(PypiPackages) } + attr_reader :pypi_packages_info + # Whether this formula should be considered outdated # if the target of the alias it was installed with has since changed. # Defaults to true. @@ -267,6 +273,9 @@ def initialize(name, path, spec, alias_path: nil, tap: nil, force_bottle: false) Tap.from_path(path) end + @pypi_packages_info = T.let(self.class.pypi_packages_info || PypiPackages.from_json_file(@tap, @name), + PypiPackages) + @full_name = T.let(T.must(full_name_with_optional_tap(name)), String) @full_alias_name = T.let(full_name_with_optional_tap(@alias_name), T.nilable(String)) @@ -3426,6 +3435,7 @@ def inherited(child) [phase, DEFAULT_NETWORK_ACCESS_ALLOWED] end, T.nilable(T::Hash[Symbol, T::Boolean])) @preserve_rpath = T.let(false, T.nilable(T::Boolean)) + @pypi_packages_info = T.let(nil, T.nilable(PypiPackages)) end end @@ -3464,6 +3474,9 @@ def on_system_blocks_exist? = !!@on_system_blocks_exist sig { returns(T.nilable(KegOnlyReason)) } attr_reader :keg_only_reason + sig { returns(T.nilable(PypiPackages)) } + attr_reader :pypi_packages_info + # A one-line description of the software. Used by users to get an overview # of the software and Homebrew maintainers. # Shows when running `brew info`. @@ -3919,6 +3932,57 @@ def head(val = nil, specs = {}, &block) end end + # Adds information about PyPI formula mapping as {PypiPackages} object. + # It provides a way to specify package name in PyPI repository, + # define extra packages, or remove them (e.g. if formula installs them as a dependency). + # + # Examples of usage: + # ```rb + # # It will use information about the PyPI package `foo` to update resources + # pypi_packages package_name: "foo" + # + # Add "extra" packages and remove unneeded ones + # depends_on "numpy" + # + # pypi_packages extra_packages: "setuptools", exclude_packages: "numpy" + # + # # Special case: empty `package_name` allows to skip resource updates for non-extra packages + # pypi_packages package_name: "", extra_packages: "setuptools" + # ``` + sig { + params( + package_name: T.nilable(String), + extra_packages: T.nilable(T.any(String, T::Array[String])), + exclude_packages: T.nilable(T.any(String, T::Array[String])), + dependencies: T.nilable(T.any(String, T::Array[String])), + needs_manual_update: T::Boolean, + ).void + } + def pypi_packages( + package_name: nil, + extra_packages: nil, + exclude_packages: nil, + dependencies: nil, + needs_manual_update: false + ) + if needs_manual_update + @pypi_packages_info = PypiPackages.new needs_manual_update: true + return + end + + if [package_name, extra_packages, exclude_packages, dependencies].all?(&:nil?) + raise ArgumentError, "must provide at least one argument" + end + + # Sadly `v1, v2, v3 = [v1, v2, v3].map { |x| Array(x) }` does not work + # for typechecker + extra_packages = Array(extra_packages) + exclude_packages = Array(exclude_packages) + dependencies = Array(dependencies) + + @pypi_packages_info = PypiPackages.new(package_name:, extra_packages:, exclude_packages:, dependencies:) + end + # Additional downloads can be defined as {resource}s and accessed in the # install method. Resources can also be defined inside a {.stable} or # {.head} block. This mechanism replaces ad-hoc "subformula" classes. diff --git a/Library/Homebrew/pypi_packages.rb b/Library/Homebrew/pypi_packages.rb new file mode 100644 index 0000000000000..dbb0ce4192614 --- /dev/null +++ b/Library/Homebrew/pypi_packages.rb @@ -0,0 +1,71 @@ +# typed: strict +# frozen_string_literal: true + +# Helper class for `pypi_packages` DSL. +# @api internal +class PypiPackages + sig { returns(T.nilable(String)) } + attr_reader :package_name + + sig { returns(T::Array[String]) } + attr_reader :extra_packages + + sig { returns(T::Array[String]) } + attr_reader :exclude_packages + + sig { returns(T::Array[String]) } + attr_reader :dependencies + + sig { params(tap: T.nilable(Tap), formula_name: String).returns(T.attached_class) } + def self.from_json_file(tap, formula_name) + list_entry = tap&.pypi_formula_mappings&.fetch(formula_name, nil) + + return new(defined_pypi_mapping: false) if list_entry.nil? + + case T.cast(list_entry, T.any(FalseClass, String, T::Hash[String, T.any(String, T::Array[String])])) + when false + new needs_manual_update: true + when String + new package_name: list_entry + when Hash + package_name = list_entry["package_name"] + extra_packages = list_entry.fetch("extra_packages", []) + exclude_packages = list_entry.fetch("exclude_packages", []) + dependencies = list_entry.fetch("dependencies", []) + + new package_name:, extra_packages:, exclude_packages:, dependencies: + end + end + + sig { + params( + package_name: T.nilable(String), + extra_packages: T::Array[String], + exclude_packages: T::Array[String], + dependencies: T::Array[String], + needs_manual_update: T::Boolean, + defined_pypi_mapping: T::Boolean, + ).void + } + def initialize( + package_name: nil, + extra_packages: [], + exclude_packages: [], + dependencies: [], + needs_manual_update: false, + defined_pypi_mapping: true + ) + @package_name = T.let(package_name, T.nilable(String)) + @extra_packages = T.let(extra_packages, T::Array[String]) + @exclude_packages = T.let(exclude_packages, T::Array[String]) + @dependencies = T.let(dependencies, T::Array[String]) + @needs_manual_update = T.let(needs_manual_update, T::Boolean) + @defined_pypi_mapping = T.let(defined_pypi_mapping, T::Boolean) + end + + sig { returns(T::Boolean) } + def defined_pypi_mapping? = @defined_pypi_mapping + + sig { returns(T::Boolean) } + def needs_manual_update? = @needs_manual_update +end diff --git a/Library/Homebrew/test/pypi_packages_spec.rb b/Library/Homebrew/test/pypi_packages_spec.rb new file mode 100644 index 0000000000000..e28f7ff4e7d39 --- /dev/null +++ b/Library/Homebrew/test/pypi_packages_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "pypi_packages" + +RSpec.describe PypiPackages do + describe ".from_json_file" do + let(:tap) { Tap.fetch("homebrew", "foo") } + let(:formula_name) { "test-formula" } + let(:mappings) { nil } + + before do + allow(tap).to receive(:pypi_formula_mappings).and_return(mappings) + end + + context "when JSON is `nil`" do + it "returns an instance with defined_pypi_mapping: false" do + pkgs = described_class.from_json_file(tap, formula_name) + expect(pkgs).to be_a(described_class) + expect(pkgs.defined_pypi_mapping?).to be(false) + expect(pkgs.needs_manual_update?).to be(false) + expect(pkgs.package_name).to be_nil + end + end + + context "when JSON is an empty hash" do + let(:mappings) { {} } + + it "returns an instance with defined_pypi_mapping: false" do + pkgs = described_class.from_json_file(tap, formula_name) + expect(pkgs).to be_a(described_class) + expect(pkgs.defined_pypi_mapping?).to be(false) + expect(pkgs.needs_manual_update?).to be(false) + expect(pkgs.package_name).to be_nil + end + end + + context "when mapping entry is `false`" do + let(:mappings) { { formula_name => false } } + + it "returns an instance with needs_manual_update: true" do + pkgs = described_class.from_json_file(tap, formula_name) + expect(pkgs).to be_a(described_class) + expect(pkgs.defined_pypi_mapping?).to be(true) + expect(pkgs.needs_manual_update?).to be(true) + expect(pkgs.package_name).to be_nil + end + end + + context "when mapping entry is a String" do + let(:mappings) { { formula_name => "bar" } } + + it "returns an instance with package_name set" do + pkgs = described_class.from_json_file(tap, formula_name) + expect(pkgs.package_name).to eq("bar") + expect(pkgs.extra_packages).to eq([]) + expect(pkgs.exclude_packages).to eq([]) + expect(pkgs.dependencies).to eq([]) + expect(pkgs.defined_pypi_mapping?).to be(true) + expect(pkgs.needs_manual_update?).to be(false) + end + end + + context "when mapping entry is `true`" do + let(:mappings) { { formula_name => true } } + + it "raises a Sorbet type error" do + expect do + described_class.from_json_file(tap, formula_name) + end.to raise_error(TypeError, /got type TrueClass/) + end + end + + context "when mapping entry is a Hash" do + let(:mappings) do + { + formula_name => { + "package_name" => "bar", + "extra_packages" => ["baz"], + "exclude_packages" => ["qux"], + "dependencies" => ["quux"], + }, + } + end + + it "returns an instance with all fields populated" do + pkgs = described_class.from_json_file(tap, formula_name) + expect(pkgs.package_name).to eq("bar") + expect(pkgs.extra_packages).to eq(["baz"]) + expect(pkgs.exclude_packages).to eq(["qux"]) + expect(pkgs.dependencies).to eq(["quux"]) + expect(pkgs.defined_pypi_mapping?).to be(true) + expect(pkgs.needs_manual_update?).to be(false) + end + end + + context "when mapping entry hash omits optional keys" do + let(:mappings) do + { formula_name => { "package_name" => "bar" } } + end + + it "fills missing keys with empty arrays" do + pkgs = described_class.from_json_file(tap, formula_name) + expect(pkgs.package_name).to eq("bar") + expect(pkgs.extra_packages).to eq([]) + expect(pkgs.exclude_packages).to eq([]) + expect(pkgs.dependencies).to eq([]) + end + end + + context "when mapping entry hash uses Array for `package_name`" do + let(:mappings) do + { formula_name => { "package_name" => ["bar"] } } + end + + it "raises a Sorbet type error" do + expect do + described_class.from_json_file(tap, formula_name) + end.to raise_error(TypeError, /got type Array/) + end + end + + context "when mapping entry hash uses String for keys" do + let(:mappings) do + { formula_name => { "extra_packages" => "bar" } } + end + + it "raises a Sorbet type error" do + expect do + described_class.from_json_file(tap, formula_name) + end.to raise_error(TypeError, /got type String/) + end + end + + context "when tap is `nil`" do + it "fills missing keys with empty arrays" do + pkgs = described_class.from_json_file(nil, formula_name) + expect(pkgs.defined_pypi_mapping?).to be(false) + expect(pkgs.package_name).to be_nil + end + end + end +end diff --git a/Library/Homebrew/utils/pypi.rb b/Library/Homebrew/utils/pypi.rb index fcf8f839ddf60..f898154d3bbde 100644 --- a/Library/Homebrew/utils/pypi.rb +++ b/Library/Homebrew/utils/pypi.rb @@ -237,23 +237,16 @@ def self.update_python_resources!(formula, version: nil, package_name: nil, extr exclude_packages: nil, dependencies: nil, install_dependencies: false, print_only: false, silent: false, verbose: false, ignore_errors: false, ignore_non_pypi_packages: false) - auto_update_list = formula.tap&.pypi_formula_mappings - if auto_update_list.present? && auto_update_list.key?(formula.full_name) && - package_name.blank? && extra_packages.blank? && exclude_packages.blank? - - list_entry = auto_update_list[formula.full_name] - case list_entry - when false - unless print_only - odie "The resources for \"#{formula.name}\" need special attention. Please update them manually." - end - when String - package_name = list_entry - when Hash - package_name = list_entry["package_name"] - extra_packages = list_entry["extra_packages"] - exclude_packages = list_entry["exclude_packages"] - dependencies = list_entry["dependencies"] + list_entry = formula.pypi_packages_info + if list_entry.defined_pypi_mapping? && package_name.blank? && extra_packages.blank? && exclude_packages.blank? + + if list_entry.needs_manual_update? && !print_only + odie "The resources for \"#{formula.name}\" need special attention. Please update them manually." + else + package_name = list_entry.package_name + extra_packages = list_entry.extra_packages + exclude_packages = list_entry.exclude_packages + dependencies = list_entry.dependencies end end