Skip to content

Commit 6457af0

Browse files
authored
Merge pull request #22963 from danielfleischer/uv-bundle-source-option
Add source option to brew bundle uv tools
2 parents 4c03d04 + f38e22f commit 6457af0

5 files changed

Lines changed: 301 additions & 58 deletions

File tree

Library/Homebrew/bundle/extensions/extension.rb

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -293,24 +293,7 @@ def self.package_installed?(name, with: nil)
293293
def self.preinstall!(name, with: nil, no_upgrade: false, verbose: false, **_options)
294294
_ = no_upgrade
295295

296-
unless package_manager_installed?
297-
puts "Installing #{package_manager_name}. It is not currently installed." if verbose
298-
Bundle.system(HOMEBREW_BREW_FILE, "install", "--formula", package_manager_name, verbose:)
299-
# `formula_versions_from_env` consumes the env vars once at startup, so
300-
# keep the cached values across reset when bootstrapping a manager.
301-
formula_versions_from_env = T.let(
302-
Bundle.formula_versions_from_env_cache,
303-
T.nilable(T::Hash[String, String]),
304-
)
305-
upgrade_formulae = Bundle.upgrade_formulae
306-
Bundle.reset!
307-
Bundle.formula_versions_from_env_cache = formula_versions_from_env
308-
Bundle.upgrade_formulae = upgrade_formulae.join(",")
309-
unless package_manager_installed?
310-
raise "Unable to install #{name} #{package_description}. " \
311-
"#{package_manager_name} installation failed."
312-
end
313-
end
296+
ensure_package_manager_installed!(name, verbose:)
314297

315298
if package_installed?(name, with:)
316299
puts "Skipping install of #{name} #{package_description}. It is already installed." if verbose
@@ -320,6 +303,28 @@ def self.preinstall!(name, with: nil, no_upgrade: false, verbose: false, **_opti
320303
true
321304
end
322305

306+
sig { params(name: String, verbose: T::Boolean).void }
307+
def self.ensure_package_manager_installed!(name, verbose: false)
308+
return if package_manager_installed?
309+
310+
puts "Installing #{package_manager_name}. It is not currently installed." if verbose
311+
Bundle.system(HOMEBREW_BREW_FILE, "install", "--formula", package_manager_name, verbose:)
312+
# `formula_versions_from_env` consumes the env vars once at startup, so
313+
# keep the cached values across reset when bootstrapping a manager.
314+
formula_versions_from_env = T.let(
315+
Bundle.formula_versions_from_env_cache,
316+
T.nilable(T::Hash[String, String]),
317+
)
318+
upgrade_formulae = Bundle.upgrade_formulae
319+
Bundle.reset!
320+
Bundle.formula_versions_from_env_cache = formula_versions_from_env
321+
Bundle.upgrade_formulae = upgrade_formulae.join(",")
322+
return if package_manager_installed?
323+
324+
raise "Unable to install #{name} #{package_description}. " \
325+
"#{package_manager_name} installation failed."
326+
end
327+
323328
sig {
324329
override.params(
325330
name: String,

Library/Homebrew/bundle/extensions/uv.rb

Lines changed: 144 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
module Homebrew
77
module Bundle
88
class Uv < Extension
9-
WithOptions = T.type_alias { T::Hash[Symbol, T::Array[String]] }
10-
Tool = T.type_alias { { name: String, with: T::Array[String] } }
9+
WithOptions = T.type_alias { T::Hash[Symbol, T.any(String, T::Array[String])] }
10+
Tool = T.type_alias { { name: String, with: T::Array[String], source: T.nilable(String) } }
1111
Checkable = T.type_alias { { name: String, options: WithOptions } }
1212
ToolEntry = T.type_alias { T.any(Tool, Checkable) }
1313

14+
SOURCE_REQUIREMENT_REGEX = T.let(%r{\A(?:git\+|https?://|file://|\.{0,2}/)|\.git\z}, Regexp)
15+
1416
class << self
1517
sig { override.returns(Symbol) }
1618
def type = :uv
@@ -23,17 +25,24 @@ def banner_name = "uv tools"
2325

2426
sig { override.params(name: String, options: Homebrew::Bundle::EntryInputOptions).returns(Dsl::Entry) }
2527
def entry(name, options = {})
26-
unknown_options = options.keys - [:with]
28+
unknown_options = options.keys - [:with, :source]
2729
raise "unknown options(#{unknown_options.inspect}) for uv" if unknown_options.present?
2830

2931
with = options[:with]
3032
if !with.nil? && (!with.is_a?(Array) || with.any? { |requirement| !requirement.is_a?(String) })
3133
raise "options[:with](#{with.inspect}) should be an Array of String objects"
3234
end
3335

36+
source = options.fetch(:source, nil)
37+
if !source.nil? && !source.is_a?(String)
38+
raise "options[:source](#{source.inspect}) should be a String object"
39+
end
40+
3441
normalized_options = {}
3542
normalized_with = normalize_with(with || [])
3643
normalized_options[:with] = normalized_with if normalized_with.present?
44+
normalized_source = normalize_source(source)
45+
normalized_options[:source] = normalized_source if normalized_source.present?
3746

3847
Dsl::Entry.new(:uv, name, normalized_options)
3948
end
@@ -55,7 +64,7 @@ def packages
5564
return packages if packages
5665

5766
@packages = if (uv = package_manager_executable)
58-
output = `#{uv} tool list --show-with --show-extras 2>/dev/null`
67+
output = `#{uv} tool list --show-with --show-extras --show-version-specifiers 2>/dev/null`
5968
parse_tool_list(output)
6069
end
6170
return [] if @packages.nil?
@@ -73,17 +82,23 @@ def dump_with(package)
7382
package_with(T.cast(package, ToolEntry))
7483
end
7584

85+
sig { params(package: Object).returns(T.nilable(String)) }
86+
def dump_source(package)
87+
package_source(T.cast(package, ToolEntry))
88+
end
89+
7690
sig {
7791
override.params(
7892
name: String,
7993
with: T.nilable(T::Array[String]),
94+
source: T.nilable(String),
8095
verbose: T::Boolean,
8196
).returns(T::Boolean)
8297
}
83-
def install_package!(name, with: nil, verbose: false)
98+
def install_package!(name, with: nil, source: nil, verbose: false)
8499
uv = package_manager_executable!
85100

86-
args = ["tool", "install", name]
101+
args = ["tool", "install", source.presence || name]
87102
normalize_with(with || []).each do |requirement|
88103
args << "--with"
89104
args << requirement
@@ -114,17 +129,29 @@ def parse_tool_list(output)
114129
extras_raw = line[/\[extras:\s*([^\]]+)\]/, 1]
115130
name = name_with_extras(name, extras_raw)
116131
with_raw = line[/\[with:\s*([^\]]+)\]/, 1]
132+
required_raw = line[/\[required:\s*([^\]]+)\]/, 1]
117133

118134
entries << {
119-
name: name,
120-
with: parse_with_requirements(with_raw),
135+
name: name,
136+
with: parse_with_requirements(with_raw),
137+
source: parse_source(required_raw),
121138
}
122139
end
123140

124141
entries.sort_by { |entry| entry[:name].to_s }
125142
end
126143
private :parse_tool_list
127144

145+
sig { params(required_raw: T.nilable(String)).returns(T.nilable(String)) }
146+
def parse_source(required_raw)
147+
source = normalize_source(required_raw)
148+
return if source.nil?
149+
return source if source.match?(SOURCE_REQUIREMENT_REGEX)
150+
151+
nil
152+
end
153+
private :parse_source
154+
128155
sig { params(name: String, extras_raw: T.nilable(String)).returns(String) }
129156
def name_with_extras(name, extras_raw)
130157
return name if extras_raw.blank?
@@ -175,6 +202,12 @@ def normalize_with(with)
175202
end
176203
private :normalize_with
177204

205+
sig { params(source: T.nilable(String)).returns(T.nilable(String)) }
206+
def normalize_source(source)
207+
source.presence&.strip
208+
end
209+
private :normalize_source
210+
178211
sig { params(name: String).returns(String) }
179212
def normalize_name(name)
180213
match = name.strip.match(/\A(?<base>[^\[\]]+)(?:\[(?<extras>[^\]]+)\])?\z/)
@@ -193,16 +226,23 @@ def normalize_name(name)
193226
end
194227
private :normalize_name
195228

196-
sig { override.params(name: String, with: T.nilable(T::Array[String])).returns(Object) }
197-
def package_record(name, with: nil)
198-
normalized_options(name, with: with || [])
229+
sig {
230+
override.params(
231+
name: String,
232+
with: T.nilable(T::Array[String]),
233+
source: T.nilable(String),
234+
).returns(Object)
235+
}
236+
def package_record(name, with: nil, source: nil)
237+
normalized_options(name, with: with || [], source:)
199238
end
200239

201-
sig { params(name: String, with: T::Array[String]).returns(Tool) }
202-
def normalized_options(name, with:)
240+
sig { params(name: String, with: T::Array[String], source: T.nilable(String)).returns(Tool) }
241+
def normalized_options(name, with:, source: nil)
203242
{
204-
name: normalize_name(name),
205-
with: normalize_with(with),
243+
name: normalize_name(name),
244+
with: normalize_with(with),
245+
source: normalize_source(source),
206246
}
207247
end
208248
private :normalized_options
@@ -223,6 +263,85 @@ def package_with(package)
223263
end
224264
private :package_with
225265

266+
sig { params(package: ToolEntry).returns(T.nilable(String)) }
267+
def package_source(package)
268+
return package[:source] if package.key?(:source)
269+
270+
T.cast(package[:options].fetch(:source, nil), T.nilable(String))
271+
end
272+
private :package_source
273+
274+
sig { override.params(package: Object).returns(String) }
275+
def dump_entry(package)
276+
line = super
277+
source = dump_source(package)
278+
line = "#{line}, source: #{quote(source)}" if source.present?
279+
280+
line
281+
end
282+
283+
sig {
284+
override.params(
285+
name: String,
286+
with: T.nilable(T::Array[String]),
287+
source: T.nilable(String),
288+
no_upgrade: T::Boolean,
289+
verbose: T::Boolean,
290+
_options: Homebrew::Bundle::EntryOption,
291+
).returns(T::Boolean)
292+
}
293+
def preinstall!(name, with: nil, source: nil, no_upgrade: false, verbose: false, **_options)
294+
_ = no_upgrade
295+
296+
ensure_package_manager_installed!(name, verbose:)
297+
298+
if package_installed?(name, with:, source:)
299+
puts "Skipping install of #{name} #{package_description}. It is already installed." if verbose
300+
return false
301+
end
302+
303+
true
304+
end
305+
306+
sig {
307+
override.params(
308+
name: String,
309+
with: T.nilable(T::Array[String]),
310+
source: T.nilable(String),
311+
preinstall: T::Boolean,
312+
no_upgrade: T::Boolean,
313+
verbose: T::Boolean,
314+
force: T::Boolean,
315+
_options: Homebrew::Bundle::EntryOption,
316+
).returns(T::Boolean)
317+
}
318+
def install!(name, with: nil, source: nil, preinstall: true, no_upgrade: false, verbose: false, force: false,
319+
**_options)
320+
_ = no_upgrade
321+
_ = force
322+
323+
return true unless preinstall
324+
325+
puts "Installing #{name} #{package_description}. It is not currently installed." if verbose
326+
return false unless install_package!(name, with:, source:, verbose:)
327+
328+
package = normalized_options(name, with: with || [], source:)
329+
installed_packages << package unless installed_packages.include?(package)
330+
packages << package unless packages.include?(package)
331+
true
332+
end
333+
334+
sig {
335+
override.params(
336+
name: String,
337+
with: T.nilable(T::Array[String]),
338+
source: T.nilable(String),
339+
).returns(T::Boolean)
340+
}
341+
def package_installed?(name, with: nil, source: nil)
342+
installed_packages.include?(package_record(name, with:, source:))
343+
end
344+
226345
sig { override.params(name: String, executable: Pathname).void }
227346
def uninstall_package!(name, executable: Pathname.new(""))
228347
Bundle.system(executable.to_s, "tool", "uninstall", name, verbose: false)
@@ -232,16 +351,18 @@ def uninstall_package!(name, executable: Pathname.new(""))
232351
sig { override.params(entries: T::Array[Dsl::Entry]).returns(T::Array[Object]) }
233352
def format_checkable(entries)
234353
checkable_entries(entries).map do |entry|
235-
with = if entry.options.is_a?(Hash)
236-
value = entry.options[:with]
237-
value.is_a?(Array) ? value : []
238-
else
239-
[]
240-
end
241-
242-
self.class.package_record(entry.name, with:)
354+
{ name: entry.name, options: entry.options }
243355
end
244356
end
357+
358+
sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) }
359+
def installed_and_up_to_date?(package, no_upgrade: false)
360+
self.class.package_installed?(
361+
self.class.dump_name(package),
362+
with: self.class.dump_with(package),
363+
source: self.class.dump_source(package),
364+
)
365+
end
245366
end
246367
end
247368
end

Library/Homebrew/test/bundle/dsl_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ def dsl_from_string(string)
119119
expect(dsl.entries[0].name).to eql("mkdocs")
120120
expect(dsl.entries[0].options).to eql(with: ["mkdocs-material<10"])
121121
end
122+
123+
it "accepts a uv source option" do
124+
dsl = dsl_from_string 'uv "ruff", source: "git+https://github.com/astral-sh/ruff.git"'
125+
expect(dsl.entries[0].options).to eql(source: "git+https://github.com/astral-sh/ruff.git")
126+
end
122127
end
123128

124129
context "with invalid input" do
@@ -158,6 +163,15 @@ def dsl_from_string(string)
158163
end.to raise_error(RuntimeError, /options\[:with\].*Array of String objects/)
159164
end
160165

166+
it "errors on invalid uv source options" do
167+
expect do
168+
dsl_from_string 'uv "ruff", source: 123'
169+
end.to raise_error(RuntimeError, /options\[:source\].*String object/)
170+
expect do
171+
dsl_from_string 'uv "ruff", branch: "main"'
172+
end.to raise_error(RuntimeError, /unknown options\(\[:branch\]\) for uv/)
173+
end
174+
161175
it "errors on invalid winget options" do
162176
expect do
163177
dsl_from_string 'winget "PowerToys", id: 123'

0 commit comments

Comments
 (0)