dependabot-uv 0.355.0 → 0.356.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,449 +1,16 @@
1
- # typed: strict
1
+ # typed: strong
2
2
  # frozen_string_literal: true
3
3
 
4
- require "sorbet-runtime"
5
-
6
- require "dependabot/uv/requirement_parser"
7
- require "dependabot/uv/requirement"
4
+ require "dependabot/python/update_checker/requirements_updater"
8
5
  require "dependabot/uv/update_checker"
9
- require "dependabot/uv/version"
10
- require "dependabot/requirements_update_strategy"
11
6
 
12
7
  module Dependabot
13
8
  module Uv
14
9
  class UpdateChecker
15
- class RequirementsUpdater
16
- extend T::Sig
17
-
18
- PYPROJECT_OR_SEPARATOR = T.let(/(?<=[a-zA-Z0-9*])\s*\|+/, Regexp)
19
- PYPROJECT_SEPARATOR = T.let(/#{PYPROJECT_OR_SEPARATOR}|,/, Regexp)
20
-
21
- class UnfixableRequirement < StandardError; end
22
-
23
- sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
24
- attr_reader :requirements
25
-
26
- sig { returns(Dependabot::RequirementsUpdateStrategy) }
27
- attr_reader :update_strategy
28
-
29
- sig { returns(T::Boolean) }
30
- attr_reader :has_lockfile
31
-
32
- sig { returns(T.nilable(Dependabot::Uv::Version)) }
33
- attr_reader :latest_resolvable_version
34
-
35
- sig do
36
- params(
37
- requirements: T::Array[T::Hash[Symbol, T.untyped]],
38
- update_strategy: Dependabot::RequirementsUpdateStrategy,
39
- has_lockfile: T::Boolean,
40
- latest_resolvable_version: T.nilable(String)
41
- ).void
42
- end
43
- def initialize(
44
- requirements:,
45
- update_strategy:,
46
- has_lockfile:,
47
- latest_resolvable_version:
48
- )
49
- @requirements = T.let(requirements, T::Array[T::Hash[Symbol, T.untyped]])
50
- @update_strategy = T.let(update_strategy, Dependabot::RequirementsUpdateStrategy)
51
- @has_lockfile = T.let(has_lockfile, T::Boolean)
52
- @latest_resolvable_version = T.let(nil, T.nilable(Dependabot::Uv::Version))
53
-
54
- return unless latest_resolvable_version
55
-
56
- @latest_resolvable_version =
57
- Uv::Version.new(latest_resolvable_version)
58
- end
59
-
60
- sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
61
- def updated_requirements
62
- return requirements if update_strategy.lockfile_only?
63
-
64
- requirements.map do |req|
65
- case req[:file]
66
- when /setup\.(?:py|cfg)$/ then updated_setup_requirement(req)
67
- when "pyproject.toml" then updated_pyproject_requirement(req)
68
- when "Pipfile" then updated_pipfile_requirement(req)
69
- when /\.txt$|\.in$/ then updated_requirement(req)
70
- else raise "Unexpected filename: #{req[:file]}"
71
- end
72
- end
73
- end
74
-
75
- private
76
-
77
- # rubocop:disable Metrics/PerceivedComplexity
78
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
79
- def updated_setup_requirement(req)
80
- return req unless latest_resolvable_version
81
- return req unless req.fetch(:requirement)
82
- return req if new_version_satisfies?(req)
83
-
84
- req_strings = req[:requirement].split(",").map(&:strip)
85
-
86
- new_requirement =
87
- if req_strings.any? { |r| requirement_class.new(r).exact? }
88
- find_and_update_equality_match(req_strings)
89
- elsif req_strings.any? { |r| r.start_with?("~=", "==") }
90
- tw_req = req_strings.find { |r| r.start_with?("~=", "==") }
91
- convert_to_range(tw_req, T.must(latest_resolvable_version))
92
- else
93
- update_requirements_range(req_strings)
94
- end
95
-
96
- req.merge(requirement: new_requirement)
97
- rescue UnfixableRequirement
98
- req.merge(requirement: :unfixable)
99
- end
100
- # rubocop:enable Metrics/PerceivedComplexity
101
-
102
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
103
- def updated_pipfile_requirement(req)
104
- # For now, we just proxy to updated_requirement. In future this
105
- # method may treat Pipfile requirements differently.
106
- updated_requirement(req)
107
- end
108
-
109
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
110
- def updated_pyproject_requirement(req)
111
- return req unless latest_resolvable_version
112
- return req unless req.fetch(:requirement)
113
- return req if new_version_satisfies?(req) && !has_lockfile
114
-
115
- # If the requirement uses || syntax then we always want to widen it
116
- return widen_pyproject_requirement(req) if req.fetch(:requirement).match?(PYPROJECT_OR_SEPARATOR)
117
-
118
- # If the requirement is a development dependency we always want to
119
- # bump it
120
- return update_pyproject_version(req) if req.fetch(:groups).include?("dev-dependencies")
121
-
122
- case update_strategy
123
- when RequirementsUpdateStrategy::WidenRanges then widen_pyproject_requirement(req)
124
- when RequirementsUpdateStrategy::BumpVersions then update_pyproject_version(req)
125
- when RequirementsUpdateStrategy::BumpVersionsIfNecessary then update_pyproject_version_if_needed(req)
126
- else raise "Unexpected update strategy: #{update_strategy}"
127
- end
128
- rescue UnfixableRequirement
129
- req.merge(requirement: :unfixable)
130
- end
131
-
132
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
133
- def update_pyproject_version_if_needed(req)
134
- return req if new_version_satisfies?(req)
135
-
136
- update_pyproject_version(req)
137
- end
138
-
139
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
140
- def update_pyproject_version(req)
141
- requirement_strings = req[:requirement].split(",").map(&:strip)
142
-
143
- new_requirement =
144
- if requirement_strings.any? { |r| r.match?(/^=|^\d/) }
145
- # If there is an equality operator, just update that. It must
146
- # be binding and any other requirements will be being ignored
147
- find_and_update_equality_match(requirement_strings)
148
- elsif requirement_strings.any? { |r| r.start_with?("~", "^") }
149
- # If a compatibility operator is being used, just bump its
150
- # version (and remove any other requirements)
151
- v_req = requirement_strings.find { |r| r.start_with?("~", "^") }
152
- bump_version(v_req, latest_resolvable_version.to_s)
153
- elsif new_version_satisfies?(req)
154
- # Otherwise we're looking at a range operator. No change
155
- # required if it's already satisfied
156
- req.fetch(:requirement)
157
- else
158
- # But if it's not, update it
159
- update_requirements_range(requirement_strings)
160
- end
161
-
162
- req.merge(requirement: new_requirement)
163
- end
164
-
165
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
166
- def widen_pyproject_requirement(req)
167
- return req if new_version_satisfies?(req)
168
-
169
- new_requirement =
170
- if req[:requirement].match?(PYPROJECT_OR_SEPARATOR)
171
- add_new_requirement_option(req[:requirement])
172
- else
173
- widen_requirement_range(req[:requirement])
174
- end
175
-
176
- req.merge(requirement: new_requirement)
177
- end
178
-
179
- sig { params(req_string: String).returns(String) }
180
- def add_new_requirement_option(req_string)
181
- option_to_copy = T.must(
182
- T.must(req_string.split(PYPROJECT_OR_SEPARATOR).last)
183
- .split(PYPROJECT_SEPARATOR).first
184
- ).strip
185
- operator = option_to_copy.gsub(/\d.*/, "").strip
186
-
187
- new_option =
188
- case operator
189
- when "", "==", "==="
190
- find_and_update_equality_match([option_to_copy])
191
- when "~=", "~", "^"
192
- bump_version(option_to_copy, latest_resolvable_version.to_s)
193
- else
194
- # We don't expect to see OR conditions used with range
195
- # operators. If / when we see it, we should handle it.
196
- raise "Unexpected operator: #{operator}"
197
- end
198
-
199
- # TODO: Match source spacing
200
- "#{req_string.strip} || #{new_option.strip}"
201
- end
202
-
203
- # rubocop:disable Metrics/PerceivedComplexity
204
- sig { params(req_string: String).returns(String) }
205
- def widen_requirement_range(req_string)
206
- requirement_strings = req_string.split(",").map(&:strip)
207
-
208
- if requirement_strings.any? { |r| r.match?(/(^=|^\d)[^*]*$/) }
209
- # If there is an equality operator, just update that.
210
- # (i.e., assume it's being used deliberately)
211
- find_and_update_equality_match(requirement_strings)
212
- elsif requirement_strings.any? { |r| r.start_with?("~", "^") } ||
213
- requirement_strings.any? { |r| r.include?("*") }
214
- # If a compatibility operator is being used, widen its
215
- # range to include the new version
216
- v_req = requirement_strings
217
- .find { |r| r.start_with?("~", "^") || r.include?("*") }
218
- convert_to_range(T.must(v_req), T.must(latest_resolvable_version))
219
- else
220
- # Otherwise we have a range, and need to update the upper bound
221
- update_requirements_range(requirement_strings)
222
- end
223
- end
224
- # rubocop:enable Metrics/PerceivedComplexity
225
-
226
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
227
- def updated_requirement(req)
228
- return req unless latest_resolvable_version
229
- return req unless req.fetch(:requirement)
230
-
231
- case update_strategy
232
- when RequirementsUpdateStrategy::WidenRanges
233
- widen_requirement(req)
234
- when RequirementsUpdateStrategy::BumpVersions
235
- update_requirement(req)
236
- when RequirementsUpdateStrategy::BumpVersionsIfNecessary
237
- update_requirement_if_needed(req)
238
- else
239
- raise "Unexpected update strategy: #{update_strategy}"
240
- end
241
- end
242
-
243
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
244
- def update_requirement_if_needed(req)
245
- return req if new_version_satisfies?(req)
246
-
247
- update_requirement(req)
248
- end
249
-
250
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
251
- def update_requirement(req)
252
- requirement_strings = req[:requirement].split(",").map(&:strip)
253
-
254
- new_requirement =
255
- if requirement_strings.any? { |r| r.match?(/^[=\d]/) }
256
- find_and_update_equality_match(requirement_strings)
257
- elsif requirement_strings.any? { |r| r.start_with?("~=") }
258
- tw_req = requirement_strings.find { |r| r.start_with?("~=") }
259
- bump_version(tw_req, latest_resolvable_version.to_s)
260
- elsif new_version_satisfies?(req)
261
- req.fetch(:requirement)
262
- else
263
- update_requirements_range(requirement_strings)
264
- end
265
- req.merge(requirement: new_requirement)
266
- rescue UnfixableRequirement
267
- req.merge(requirement: :unfixable)
268
- end
269
-
270
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
271
- def widen_requirement(req)
272
- return req if new_version_satisfies?(req)
273
-
274
- new_requirement = widen_requirement_range(req[:requirement])
275
-
276
- req.merge(requirement: new_requirement)
277
- end
278
-
279
- sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
280
- def new_version_satisfies?(req)
281
- requirement_class
282
- .requirements_array(req.fetch(:requirement))
283
- .any? { |r| r.satisfied_by?(T.must(latest_resolvable_version)) }
284
- end
285
-
286
- sig { params(requirement_strings: T::Array[String]).returns(String) }
287
- def find_and_update_equality_match(requirement_strings)
288
- if requirement_strings.any? { |r| requirement_class.new(r).exact? }
289
- # True equality match
290
- T.must(requirement_strings.find { |r| requirement_class.new(r).exact? })
291
- .sub(
292
- RequirementParser::VERSION,
293
- T.must(latest_resolvable_version).to_s
294
- )
295
- else
296
- # Prefix match
297
- T.must(requirement_strings.find { |r| r.match?(/^(=+|\d)/) })
298
- .sub(RequirementParser::VERSION) do |v|
299
- at_same_precision(T.must(latest_resolvable_version).to_s, v)
300
- end
301
- end
302
- end
303
-
304
- sig { params(new_version: String, old_version: String).returns(String) }
305
- def at_same_precision(new_version, old_version)
306
- # return new_version unless old_version.include?("*")
307
-
308
- count = old_version.split(".").count
309
- precision = old_version.split(".").index("*") || count
310
-
311
- new_version
312
- .split(".")
313
- .first(count)
314
- .map.with_index { |s, i| i < precision ? s : "*" }
315
- .join(".")
316
- end
317
-
318
- sig { params(requirement_strings: T::Array[String]).returns(String) }
319
- def update_requirements_range(requirement_strings)
320
- ruby_requirements =
321
- requirement_strings.map { |r| requirement_class.new(r) }
322
-
323
- updated_requirement_strings = ruby_requirements.flat_map do |r|
324
- next r.to_s if r.satisfied_by?(T.must(latest_resolvable_version))
325
-
326
- case op = r.requirements.first.first
327
- when "<"
328
- "<" + update_greatest_version(r.requirements.first.last, T.must(latest_resolvable_version))
329
- when "<="
330
- "<=" + latest_resolvable_version.to_s
331
- when "!=", ">", ">="
332
- raise UnfixableRequirement
333
- else
334
- raise "Unexpected op for unsatisfied requirement: #{op}"
335
- end
336
- end.compact
337
-
338
- updated_requirement_strings
339
- .sort_by { |r| requirement_class.new(r).requirements.first.last }
340
- .map(&:to_s).join(",").delete(" ")
341
- end
342
-
343
- # Updates the version in a constraint to be the given version
344
- sig { params(req_string: String, version_to_be_permitted: String).returns(String) }
345
- def bump_version(req_string, version_to_be_permitted)
346
- old_version = T.must(
347
- T.must(
348
- req_string
349
- .match(/(#{RequirementParser::VERSION})/o)
350
- )
351
- .captures.first
352
- )
353
-
354
- req_string.sub(
355
- old_version,
356
- at_same_precision(version_to_be_permitted, old_version)
357
- )
358
- end
359
-
360
- sig { params(req_string: String, version_to_be_permitted: Dependabot::Uv::Version).returns(String) }
361
- def convert_to_range(req_string, version_to_be_permitted)
362
- # Construct an upper bound at the same precision that the original
363
- # requirement was at (taking into account ~ dynamics)
364
- index_to_update = index_to_update_for(req_string)
365
- ub_segments = T.let(version_to_be_permitted.segments, T::Array[T.any(String, Integer)])
366
- ub_segments << "0" while ub_segments.count <= index_to_update
367
- ub_segments = T.must(ub_segments[0..index_to_update])
368
- ub_segments[index_to_update] = T.must(ub_segments[index_to_update]).to_i + 1
369
-
370
- lb_segments = lower_bound_segments_for_req(req_string)
371
-
372
- # Ensure versions have the same length as each other (cosmetic)
373
- length = [lb_segments.count, ub_segments.count].max
374
- lb_segments.fill(0, lb_segments.count...length)
375
- ub_segments.fill(0, ub_segments.count...length)
376
-
377
- ">=#{lb_segments.join('.')},<#{ub_segments.join('.')}"
378
- end
379
-
380
- sig { params(req_string: String).returns(T::Array[Integer]) }
381
- def lower_bound_segments_for_req(req_string)
382
- requirement = requirement_class.new(req_string)
383
- version = requirement.requirements.first.last
384
- version = version.release if version.prerelease?
385
-
386
- lb_segments = version.segments
387
- lb_segments.pop while lb_segments.last.zero?
388
-
389
- lb_segments
390
- end
391
-
392
- sig { params(req_string: String).returns(Integer) }
393
- def index_to_update_for(req_string)
394
- req = requirement_class.new(req_string.split(/[.\-]\*/).first)
395
- version = req.requirements.first.last.release
396
-
397
- if req_string.strip.start_with?("^")
398
- version.segments.index { |i| i != 0 }
399
- elsif req_string.include?("*")
400
- version.segments.count - 1
401
- elsif req_string.strip.start_with?("~=", "==")
402
- version.segments.count - 2
403
- elsif req_string.strip.start_with?("~")
404
- req_string.split(".").one? ? 0 : 1
405
- else
406
- raise "Don't know how to convert #{req_string} to range"
407
- end
408
- end
409
-
410
- # Updates the version in a "<" constraint to allow the given version
411
- sig do
412
- params(
413
- version: Gem::Version,
414
- version_to_be_permitted: T.any(String, Dependabot::Uv::Version)
415
- ).returns(String)
416
- end
417
- def update_greatest_version(version, version_to_be_permitted)
418
- if version_to_be_permitted.is_a?(String)
419
- version_to_be_permitted =
420
- Uv::Version.new(version_to_be_permitted)
421
- end
422
- version = version.release if version.prerelease?
423
-
424
- index_to_update = [
425
- version.segments.map.with_index { |n, i| n.to_i.zero? ? 0 : i }.max,
426
- version_to_be_permitted.segments.count - 1
427
- ].min
428
-
429
- new_segments = version.segments.map.with_index do |_, index|
430
- if index < index_to_update
431
- version_to_be_permitted.segments[index]
432
- elsif index == index_to_update
433
- version_to_be_permitted.segments[index].to_i + 1
434
- else
435
- 0
436
- end
437
- end
438
-
439
- new_segments.join(".")
440
- end
441
-
442
- sig { returns(T.class_of(Dependabot::Uv::Requirement)) }
443
- def requirement_class
444
- Uv::Requirement
445
- end
446
- end
10
+ # UV uses the same requirements update logic as Python.
11
+ # Both ecosystems share Version and Requirement classes (via aliases),
12
+ # so we reuse Python's RequirementsUpdater implementation.
13
+ RequirementsUpdater = Dependabot::Python::UpdateChecker::RequirementsUpdater
447
14
  end
448
15
  end
449
16
  end