Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Library/Homebrew/ast_constants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
64 changes: 64 additions & 0 deletions Library/Homebrew/formula.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
71 changes: 71 additions & 0 deletions Library/Homebrew/pypi_packages.rb
Original file line number Diff line number Diff line change
@@ -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
142 changes: 142 additions & 0 deletions Library/Homebrew/test/pypi_packages_spec.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 10 additions & 17 deletions Library/Homebrew/utils/pypi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down