dependabot-uv 0.392.0 → 0.394.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1c6b85902cc78b5c3624f68a39a32e05a4d5206081bac206d54e8afa732489f
4
- data.tar.gz: 401140b860009a2650a5b96fde815fc50f3813ad60104516d5d50c8f9635799c
3
+ metadata.gz: f9eee59202ca318e612e23d7303309e498d51999cda642a1660d41390a2bd4a8
4
+ data.tar.gz: 3b3bb2ea60f190db5d8c9bd36f1ff01531097788c3755c6638d5a4e8e3ebf923
5
5
  SHA512:
6
- metadata.gz: 399a2740238ae91547e49246a5fd309bfe228d2efbf05f9d8c9699201960158e92e115dd625b940a7a49a7664f683777957a371d9e5326af74823d883947a6f2
7
- data.tar.gz: 7a2307ab70008dcbb760977f20c226e3b659df68cc4adf867f237e8196db5fc7462f18a10ef5cfe5ba008d04641cc009e40b8def58596c70b1bc5346e0bae253
6
+ metadata.gz: 2f6d9c0f1ea2c05bab8acea5563a4a74c016ab9b8bd77639afe9468a8f7b8cb18d238585b5bdc11b255bf8e8d0af01d7d470c07ab9b83792d8001156bb73a199
7
+ data.tar.gz: '09c83e67c0b94adf7ad3f848f17521b57e282ef33db9d4ac0b5991d0ea8135024fc9eb5e78a1515a7f9f4c826f8db3347e25b24ed842ed85681a0b3dd8289e94'
@@ -1,13 +1,13 @@
1
- pip==26.1.1
2
- pip-tools==7.5.3
1
+ pip==26.2.1
2
+ pip-tools==7.6.1
3
3
  flake8==7.3.0
4
4
  hashin==1.0.5
5
5
  pipenv==2024.4.1
6
6
  plette==2.2.1
7
- poetry==2.3.4
7
+ poetry==2.4.1
8
8
  # TODO: Replace 3p package `tomli` with 3.11's new stdlib `tomllib` once we drop support for Python 3.10.
9
9
  tomli==2.4.1
10
- uv==0.12.1
10
+ uv==0.12.5
11
11
 
12
12
  # Some dependencies will only install if Cython is present
13
- Cython==3.2.4
13
+ Cython==3.3.0
@@ -277,8 +277,8 @@ module Dependabot
277
277
  normalized_directory = directory.gsub(%r{(^/|/$)}, "")
278
278
 
279
279
  repo_contents(dir: base_dir, raise_errors: false)
280
- .select { |file| T.unsafe(file).type == "dir" }
281
- .map { |f| T.unsafe(f).path.gsub(%r{^/?#{Regexp.escape(normalized_directory)}/?}, "") }
280
+ .select { |file| file.type == "dir" }
281
+ .map { |file| file.path.gsub(%r{^/?#{Regexp.escape(normalized_directory)}/?}, "") }
282
282
  end
283
283
 
284
284
  sig { params(paths: T::Array[String], pattern: String).returns(T::Array[String]) }
@@ -276,7 +276,7 @@ module Dependabot
276
276
  def req_files_for_dir(requirements_dir)
277
277
  dir = directory.gsub(%r{(^/|/$)}, "")
278
278
  relative_reqs_dir =
279
- requirements_dir.path&.gsub(%r{^/?#{Regexp.escape(dir)}/?}, "")
279
+ requirements_dir.path.gsub(%r{^/?#{Regexp.escape(dir)}/?}, "")
280
280
 
281
281
  fetch_requirement_files_from_path(relative_reqs_dir)
282
282
  end
@@ -321,7 +321,7 @@ module Dependabot
321
321
  sig { returns(T::Array[Dependabot::DependencyFile]) }
322
322
  def fetch_requirement_files_from_dirs
323
323
  repo_contents
324
- .select { |f| T.unsafe(f).type == "dir" }
324
+ .select { |f| f.type == "dir" }
325
325
  .flat_map { |dir| req_files_for_dir(dir) }
326
326
  end
327
327
 
@@ -333,10 +333,10 @@ module Dependabot
333
333
  end
334
334
  def filter_requirement_files(contents, base_path: nil)
335
335
  contents
336
- .select { |f| T.unsafe(f).type == "file" }
337
- .select { |f| file_matches_requirement_pattern?(T.unsafe(f).name) }
338
- .reject { |f| T.unsafe(f).size > MAX_FILE_SIZE }
339
- .map { |f| fetch_file_with_path(T.unsafe(f).name, base_path) }
336
+ .select { |f| f.type == "file" }
337
+ .select { |f| file_matches_requirement_pattern?(f.name) }
338
+ .reject { |f| f.size > MAX_FILE_SIZE }
339
+ .map { |f| fetch_file_with_path(f.name, base_path) }
340
340
  .select { |f| T.must(REQUIREMENT_FILE_PATTERNS[:filenames]).include?(f.name) || requirements_file?(f) }
341
341
  end
342
342
 
@@ -1,4 +1,4 @@
1
- # typed: strict
1
+ # typed: strong
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "toml-rb"
@@ -9,6 +9,9 @@ require "dependabot/uv/file_parser"
9
9
  require "dependabot/uv/requirement"
10
10
  require "dependabot/errors"
11
11
  require "dependabot/uv/name_normaliser"
12
+ require "dependabot/python/file_parser/pep_dependency"
13
+ require "dependabot/python/file_parser/poetry_lock"
14
+ require "dependabot/python/file_parser/pyproject_document"
12
15
 
13
16
  module Dependabot
14
17
  module Uv
@@ -16,6 +19,10 @@ module Dependabot
16
19
  class PyprojectFilesParser
17
20
  extend T::Sig
18
21
 
22
+ PepDependency = Dependabot::Python::FileParser::PepDependency
23
+ PoetryLock = Dependabot::Python::FileParser::PoetryLock
24
+ PyprojectDocument = Dependabot::Python::FileParser::PyprojectDocument
25
+
19
26
  POETRY_DEPENDENCY_TYPES = %w(dependencies dev-dependencies).freeze
20
27
 
21
28
  # https://python-poetry.org/docs/dependency-specification/
@@ -24,6 +31,8 @@ module Dependabot
24
31
  sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void }
25
32
  def initialize(dependency_files:)
26
33
  @dependency_files = dependency_files
34
+ @pyproject_document = T.let(nil, T.nilable(PyprojectDocument))
35
+ @poetry_lock_document = T.let(nil, T.nilable(PoetryLock))
27
36
  end
28
37
 
29
38
  sig { returns(Dependabot::FileParsers::Base::DependencySet) }
@@ -61,13 +70,11 @@ module Dependabot
61
70
  dependencies = Dependabot::FileParsers::Base::DependencySet.new
62
71
 
63
72
  POETRY_DEPENDENCY_TYPES.each do |type|
64
- deps_hash = T.must(poetry_root)[type] || {}
65
- dependencies += parse_poetry_dependency_group(type, deps_hash)
73
+ dependencies += parse_poetry_dependency_group(type, pyproject_document.poetry_dependencies(type))
66
74
  end
67
75
 
68
- groups = T.must(poetry_root)["group"] || {}
69
- groups.each do |group, group_spec|
70
- dependencies += parse_poetry_dependency_group(group, group_spec["dependencies"])
76
+ pyproject_document.poetry_groups.each do |group, dependencies_by_name|
77
+ dependencies += parse_poetry_dependency_group(group, dependencies_by_name)
71
78
  end
72
79
  dependencies
73
80
  end
@@ -85,20 +92,20 @@ module Dependabot
85
92
  parse_pep621_pep735_dependencies(pyproject_file).each do |dep|
86
93
  # If a requirement has a `<` or `<=` marker then updating it is
87
94
  # probably blocked. Ignore it.
88
- next if dep["markers"]&.include?("<")
95
+ next if dep.markers&.include?("<")
89
96
 
90
97
  # In uv no constraint means any version is acceptable
91
- requirement_value = dep["requirement"] && dep["requirement"].empty? ? "*" : dep["requirement"]
98
+ requirement_value = dep.requirement == "" ? "*" : dep.requirement
92
99
 
93
100
  dependencies <<
94
101
  Dependency.new(
95
- name: normalised_name(dep["name"], dep["extras"]),
96
- version: dep["version"]&.include?("*") ? nil : dep["version"],
102
+ name: normalised_name(dep.name, dep.extras),
103
+ version: dep.version&.include?("*") ? nil : dep.version,
97
104
  requirements: [{
98
105
  requirement: requirement_value,
99
- file: Pathname.new(dep["file"]).cleanpath.to_path,
106
+ file: Pathname.new(dep.file).cleanpath.to_path,
100
107
  source: nil,
101
- groups: [dep["requirement_type"]].compact
108
+ groups: [dep.requirement_type].compact
102
109
  }],
103
110
  package_manager: "uv"
104
111
  )
@@ -109,7 +116,52 @@ module Dependabot
109
116
 
110
117
  sig { returns(T::Array[Dependabot::DependencyFile]) }
111
118
  def workspace_member_pyproject_files
112
- dependency_files.select { |file| file.support_file? && file.name.end_with?("pyproject.toml") }
119
+ return [] if workspace_member_patterns.empty?
120
+
121
+ dependency_files.select do |file|
122
+ next false unless file.support_file? && file.name.end_with?("pyproject.toml")
123
+
124
+ workspace_member_file?(file)
125
+ end
126
+ end
127
+
128
+ # Only pyprojects declared under `[tool.uv.workspace].members` in the root
129
+ # manifest are true workspace members whose requirements should be updated.
130
+ # Editable `[tool.uv.sources]` path dependencies are separate packages that
131
+ # are fetched as support files for resolution but must not be modified.
132
+ sig { returns(T::Array[String]) }
133
+ def workspace_member_patterns
134
+ workspace_globs("members")
135
+ end
136
+
137
+ # uv removes paths matching `[tool.uv.workspace].exclude` after expanding
138
+ # `members`, so an excluded directory is not a workspace member even when it
139
+ # matches a `members` glob.
140
+ sig { returns(T::Array[String]) }
141
+ def workspace_exclude_patterns
142
+ workspace_globs("exclude")
143
+ end
144
+
145
+ sig { params(key: String).returns(T::Array[String]) }
146
+ def workspace_globs(key)
147
+ return [] unless pyproject
148
+
149
+ pyproject_document.workspace_globs(key)
150
+ end
151
+
152
+ sig { params(file: Dependabot::DependencyFile).returns(T::Boolean) }
153
+ def workspace_member_file?(file)
154
+ member_dir = File.dirname(file.name)
155
+
156
+ return false if workspace_exclude_patterns.any? { |pattern| glob_matches_dir?(pattern, member_dir) }
157
+
158
+ workspace_member_patterns.any? { |pattern| glob_matches_dir?(pattern, member_dir) }
159
+ end
160
+
161
+ sig { params(pattern: String, dir: String).returns(T::Boolean) }
162
+ def glob_matches_dir?(pattern, dir)
163
+ normalized_pattern = pattern.gsub(%r{^\./}, "").chomp("/")
164
+ File.fnmatch?(normalized_pattern, dir, File::FNM_PATHNAME)
113
165
  end
114
166
 
115
167
  sig { returns(Dependabot::FileParsers::Base::DependencySet) }
@@ -126,17 +178,16 @@ module Dependabot
126
178
  sig do
127
179
  params(
128
180
  type: String,
129
- deps_hash: T::Hash[String,
130
- T.untyped]
181
+ deps_hash: PyprojectDocument::PoetryDependencyMap
131
182
  ).returns(Dependabot::FileParsers::Base::DependencySet)
132
183
  end
133
184
  def parse_poetry_dependency_group(type, deps_hash)
134
185
  dependencies = Dependabot::FileParsers::Base::DependencySet.new
135
186
 
136
- deps_hash.each do |name, req|
187
+ deps_hash.each do |name, requirements_data|
137
188
  next if normalise(name) == "python"
138
189
 
139
- requirements = parse_requirements_from(req, type)
190
+ requirements = parse_requirements_from(requirements_data, type)
140
191
  next if requirements.empty?
141
192
 
142
193
  dependencies << Dependency.new(
@@ -154,57 +205,62 @@ module Dependabot
154
205
  NameNormaliser.normalise_including_extras(name, extras)
155
206
  end
156
207
 
157
- # @param req can be an Array, Hash or String that represents the constraints for a dependency
158
- sig { params(req: T.untyped, type: String).returns(T::Array[T::Hash[Symbol, T.nilable(String)]]) }
159
- def parse_requirements_from(req, type)
160
- [req].flatten.compact.filter_map do |requirement|
161
- next if requirement.is_a?(Hash) && UNSUPPORTED_DEPENDENCY_TYPES.intersect?(requirement.keys)
208
+ sig do
209
+ params(
210
+ requirements_data: T::Array[PyprojectDocument::PoetryRequirement],
211
+ type: String
212
+ ).returns(T::Array[Dependabot::Dependency::RequirementInput])
213
+ end
214
+ def parse_requirements_from(requirements_data, type)
215
+ requirements_data.filter_map { |requirement| poetry_requirement(requirement, type) }
216
+ end
162
217
 
218
+ sig do
219
+ params(
220
+ requirement: PyprojectDocument::PoetryRequirement,
221
+ type: String
222
+ ).returns(T.nilable(Dependabot::Dependency::RequirementInput))
223
+ end
224
+ def poetry_requirement(requirement, type)
225
+ if requirement.is_a?(String)
163
226
  check_requirements(requirement)
164
-
165
- if requirement.is_a?(String)
166
- {
167
- requirement: requirement,
168
- file: T.must(pyproject).name,
169
- source: nil,
170
- groups: [type]
171
- }
172
- else
173
- {
174
- requirement: requirement["version"],
175
- file: T.must(pyproject).name,
176
- source: requirement.fetch("source", nil),
177
- groups: [type]
178
- }
179
- end
227
+ return {
228
+ requirement: requirement,
229
+ file: T.must(pyproject).name,
230
+ source: nil,
231
+ groups: [type]
232
+ }
180
233
  end
234
+
235
+ return if UNSUPPORTED_DEPENDENCY_TYPES.intersect?(requirement.keys)
236
+
237
+ check_requirements(requirement)
238
+ {
239
+ requirement: requirement_string(requirement, "version"),
240
+ file: T.must(pyproject).name,
241
+ source: poetry_source(requirement["source"]),
242
+ groups: [type]
243
+ }
181
244
  end
182
245
 
183
- sig { returns(T.nilable(T::Boolean)) }
246
+ sig { returns(T::Boolean) }
184
247
  def using_poetry?
185
- !poetry_root.nil?
248
+ pyproject_document.poetry?
186
249
  end
187
250
 
188
251
  sig { returns(T::Boolean) }
189
252
  def using_pep621?
190
- !parsed_pyproject.dig("project", "dependencies").nil? ||
191
- !parsed_pyproject.dig("project", "optional-dependencies").nil? ||
192
- !parsed_pyproject.dig("build-system", "requires").nil?
193
- end
194
-
195
- sig { returns(T.nilable(T::Hash[String, T.untyped])) }
196
- def poetry_root
197
- parsed_pyproject.dig("tool", "poetry")
253
+ pyproject_document.pep621?
198
254
  end
199
255
 
200
256
  sig { returns(T::Boolean) }
201
257
  def using_pep735?
202
- parsed_pyproject.key?("dependency-groups")
258
+ pyproject_document.pep735?
203
259
  end
204
260
 
205
- sig { returns(T.untyped) }
261
+ sig { returns(T::Boolean) }
206
262
  def using_pdm?
207
- using_pep621? && pdm_lock
263
+ using_pep621? && !pdm_lock.nil?
208
264
  end
209
265
 
210
266
  # Create a DependencySet where each element has no requirement. Any
@@ -215,15 +271,15 @@ module Dependabot
215
271
  dependencies = Dependabot::FileParsers::Base::DependencySet.new
216
272
 
217
273
  source_types = %w(directory git url)
218
- parsed_lockfile.fetch("package", []).each do |details|
219
- next if source_types.include?(details.dig("source", "type"))
274
+ poetry_lock_document.packages.each do |package|
275
+ next if source_types.include?(package.source_type)
220
276
 
221
- name = normalise(details.fetch("name"))
277
+ name = normalise(package.name)
222
278
 
223
279
  dependencies <<
224
280
  Dependency.new(
225
281
  name: name,
226
- version: details.fetch("version"),
282
+ version: package.version,
227
283
  requirements: [],
228
284
  package_manager: "uv",
229
285
  subdependency_metadata: [{
@@ -246,8 +302,10 @@ module Dependabot
246
302
  sig { returns(T::Array[T.nilable(String)]) }
247
303
  def parse_production_dependency_names
248
304
  SharedHelpers.in_a_temporary_directory do
249
- File.write(T.must(pyproject).name, T.must(pyproject).content)
250
- File.write(lockfile.name, lockfile.content)
305
+ pyproject_file = T.must(pyproject)
306
+ lockfile_file = T.must(lockfile)
307
+ File.write(pyproject_file.name, pyproject_file.content)
308
+ File.write(lockfile_file.name, lockfile_file.content)
251
309
 
252
310
  begin
253
311
  output = SharedHelpers.run_shell_command("pyenv exec poetry show --only main")
@@ -266,18 +324,19 @@ module Dependabot
266
324
  end
267
325
  end
268
326
 
269
- sig { params(dep_name: String).returns(T.untyped) }
327
+ sig { params(dep_name: String).returns(T.nilable(String)) }
270
328
  def version_from_lockfile(dep_name)
271
- return unless parsed_lockfile
329
+ return unless poetry_lock
272
330
 
273
- parsed_lockfile.fetch("package", [])
274
- .find { |p| normalise(p.fetch("name")) == normalise(dep_name) }
275
- &.fetch("version", nil)
331
+ poetry_lock_document.version_for(dep_name) { |name| normalise(name) }
276
332
  end
277
333
 
278
- sig { params(req: T.untyped).returns(T::Array[Dependabot::Uv::Requirement]) }
334
+ sig do
335
+ params(req: PyprojectDocument::PoetryRequirement)
336
+ .returns(T::Array[Dependabot::Uv::Requirement])
337
+ end
279
338
  def check_requirements(req)
280
- requirement = req.is_a?(String) ? req : req["version"]
339
+ requirement = req.is_a?(String) ? req : requirement_string(req, "version")
281
340
  Uv::Requirement.requirements_array(requirement)
282
341
  rescue Gem::Requirement::BadRequirementError => e
283
342
  raise Dependabot::DependencyFileNotEvaluatable, e.message
@@ -288,21 +347,14 @@ module Dependabot
288
347
  NameNormaliser.normalise(name)
289
348
  end
290
349
 
291
- sig { returns(T::Hash[String, T.untyped]) }
292
- def parsed_pyproject
293
- @parsed_pyproject ||= T.let(TomlRB.parse(T.must(pyproject).content), T.nilable(T::Hash[String, T.untyped]))
294
- rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
295
- raise Dependabot::DependencyFileNotParseable, T.must(pyproject).path
350
+ sig { returns(PyprojectDocument) }
351
+ def pyproject_document
352
+ @pyproject_document ||= PyprojectDocument.from_file(T.must(pyproject))
296
353
  end
297
354
 
298
- sig { returns(T::Hash[String, T.untyped]) }
299
- def parsed_poetry_lock
300
- @parsed_poetry_lock ||= T.let(
301
- TomlRB.parse(T.must(poetry_lock).content),
302
- T.nilable(T::Hash[String, T.untyped])
303
- )
304
- rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
305
- raise Dependabot::DependencyFileNotParseable, T.must(poetry_lock).path
355
+ sig { returns(PoetryLock) }
356
+ def poetry_lock_document
357
+ @poetry_lock_document ||= PoetryLock.from_file(T.must(poetry_lock))
306
358
  end
307
359
 
308
360
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
@@ -313,20 +365,22 @@ module Dependabot
313
365
  )
314
366
  end
315
367
 
316
- sig { returns(T.untyped) }
368
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
317
369
  def lockfile
318
370
  poetry_lock
319
371
  end
320
372
 
321
- sig { params(pyproject_file: Dependabot::DependencyFile).returns(T.untyped) }
373
+ sig { params(pyproject_file: Dependabot::DependencyFile).returns(T::Array[PepDependency]) }
322
374
  def parse_pep621_pep735_dependencies(pyproject_file)
323
375
  SharedHelpers.in_a_temporary_directory do
324
376
  write_temporary_pyproject(pyproject_file)
325
377
 
326
- SharedHelpers.run_helper_subprocess(
327
- command: "pyenv exec python3 #{NativeHelpers.python_helper_path}",
328
- function: "parse_pep621_pep735_dependencies",
329
- args: [pyproject_file.name]
378
+ PepDependency.from_helper_result(
379
+ SharedHelpers.run_helper_subprocess(
380
+ command: "pyenv exec python3 #{NativeHelpers.python_helper_path}",
381
+ function: "parse_pep621_pep735_dependencies",
382
+ args: [pyproject_file.name]
383
+ )
330
384
  )
331
385
  end
332
386
  end
@@ -338,11 +392,6 @@ module Dependabot
338
392
  File.write(path, pyproject_file.content)
339
393
  end
340
394
 
341
- sig { returns(T.untyped) }
342
- def parsed_lockfile
343
- parsed_poetry_lock if poetry_lock
344
- end
345
-
346
395
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
347
396
  def poetry_lock
348
397
  @poetry_lock ||= T.let(
@@ -351,6 +400,36 @@ module Dependabot
351
400
  )
352
401
  end
353
402
 
403
+ sig do
404
+ params(
405
+ requirement: T::Hash[String, Object],
406
+ key: String
407
+ ).returns(T.nilable(String))
408
+ end
409
+ def requirement_string(requirement, key)
410
+ value = requirement[key]
411
+ return if value.nil?
412
+ return value if value.is_a?(String)
413
+
414
+ raise TypeError, "Poetry dependency #{key} must be a string or nil"
415
+ end
416
+
417
+ sig { params(value: T.nilable(Object)).returns(T.nilable(Dependabot::DependencyRequirement::Source)) }
418
+ def poetry_source(value)
419
+ return if value.nil?
420
+ return value if value.is_a?(String)
421
+ raise TypeError, "Poetry dependency source must be a string, object, or nil" unless value.is_a?(Hash)
422
+
423
+ source = T.let({}, Dependabot::DependencyRequirement::ObjectHash)
424
+ value.each do |raw_key, raw_value|
425
+ key = T.cast(raw_key, Object)
426
+ raise TypeError, "Poetry dependency source keys must be strings" unless key.is_a?(String)
427
+
428
+ source[key] = T.cast(raw_value, Object)
429
+ end
430
+ source
431
+ end
432
+
354
433
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
355
434
  def pdm_lock
356
435
  @pdm_lock ||= T.let(
@@ -44,21 +44,33 @@ module Dependabot
44
44
  sig { returns(T.nilable(String)) }
45
45
  attr_reader :repo_contents_path
46
46
 
47
+ sig { returns(T.nilable(String)) }
48
+ attr_reader :target_requirement
49
+
47
50
  sig do
48
51
  params(
49
52
  dependencies: T::Array[Dependency],
50
53
  dependency_files: T::Array[DependencyFile],
51
54
  credentials: T::Array[Dependabot::Credential],
52
55
  index_urls: T.nilable(T::Array[T.nilable(String)]),
53
- repo_contents_path: T.nilable(String)
56
+ repo_contents_path: T.nilable(String),
57
+ target_requirement: T.nilable(String)
54
58
  ).void
55
59
  end
56
- def initialize(dependencies:, dependency_files:, credentials:, index_urls: nil, repo_contents_path: nil)
60
+ def initialize(
61
+ dependencies:,
62
+ dependency_files:,
63
+ credentials:,
64
+ index_urls: nil,
65
+ repo_contents_path: nil,
66
+ target_requirement: nil
67
+ )
57
68
  @dependencies = dependencies
58
69
  @dependency_files = dependency_files
59
70
  @credentials = credentials
60
71
  @index_urls = index_urls
61
72
  @repo_contents_path = repo_contents_path
73
+ @target_requirement = target_requirement
62
74
  @prepared_pyproject = T.let(nil, T.nilable(String))
63
75
  @updated_lockfile_content = T.let(nil, T.nilable(String))
64
76
  @pyproject = T.let(nil, T.nilable(Dependabot::DependencyFile))
@@ -112,7 +124,9 @@ module Dependabot
112
124
  # Use updated_lockfile_content which might raise if the lockfile doesn't change
113
125
  new_content = updated_lockfile_content
114
126
 
115
- raise "Expected lockfile to change!" if T.must(lockfile).content == new_content
127
+ if T.must(lockfile).content == new_content
128
+ raise DependencyFileContentNotChanged, "Expected lockfile to change!"
129
+ end
116
130
 
117
131
  updated_files << updated_file(file: T.must(lockfile), content: new_content)
118
132
  end
@@ -291,7 +305,14 @@ module Dependabot
291
305
  # Strip extras from the package name for the uv lock command
292
306
  # uv lock --upgrade-package expects the base package name without extras
293
307
  base_dep_name = normalise(dep_name)
294
- package_spec = dep_version ? "#{base_dep_name}==#{dep_version}" : base_dep_name
308
+ package_spec =
309
+ if target_requirement
310
+ "#{base_dep_name}#{target_requirement}"
311
+ elsif dep_version
312
+ "#{base_dep_name}==#{dep_version}"
313
+ else
314
+ base_dep_name
315
+ end
295
316
 
296
317
  command = "pyenv exec uv lock --upgrade-package #{package_spec} #{options}"
297
318
  fingerprint = "pyenv exec uv lock --upgrade-package <dependency_name> #{options_fingerprint}"
@@ -2,9 +2,17 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "sorbet-runtime"
5
+ require "toml-rb"
5
6
 
7
+ require "dependabot/errors"
6
8
  require "dependabot/package/release_cooldown_options"
9
+ require "dependabot/update_checkers/version_filters"
7
10
  require "dependabot/uv/version"
11
+ require "dependabot/uv/file_updater/lock_file_error_handler"
12
+ require "dependabot/uv/file_updater/lock_file_updater"
13
+ require "dependabot/uv/file_parser/python_requirement_parser"
14
+ require "dependabot/uv/language_version_manager"
15
+ require "dependabot/uv/name_normaliser"
8
16
  require "dependabot/uv/requirement"
9
17
  require "dependabot/uv/update_checker"
10
18
  require "dependabot/uv/update_checker/latest_version_finder"
@@ -42,33 +50,42 @@ module Dependabot
42
50
  @security_advisories = security_advisories
43
51
  @ignored_versions = ignored_versions
44
52
  @update_cooldown = update_cooldown
53
+ @latest_resolvable_versions = T.let(
54
+ {},
55
+ T::Hash[String, T.nilable(Dependabot::Uv::Version)]
56
+ )
57
+ @resolved_versions = T.let(
58
+ {},
59
+ T::Hash[String, T.nilable(Dependabot::Uv::Version)]
60
+ )
61
+ @resolvable_versions = T.let({}, T::Hash[String, T::Boolean])
45
62
  end
46
63
 
47
64
  sig { params(requirement: T.nilable(String)).returns(T.nilable(Dependabot::Uv::Version)) }
48
65
  def latest_resolvable_version(requirement:)
49
66
  return nil unless requirement
67
+ return @latest_resolvable_versions[requirement] if @latest_resolvable_versions.key?(requirement)
50
68
 
51
- req = Uv::Requirement.new(requirement)
52
-
53
- # Get the version from the dependency if available
54
- version_from_dependency = dependency.version && Uv::Version.new(dependency.version)
55
- return version_from_dependency if version_from_dependency && req.satisfied_by?(version_from_dependency)
56
-
57
- nil
69
+ @latest_resolvable_versions[requirement] = fetch_latest_resolvable_version(requirement)
58
70
  end
59
71
 
60
- sig { params(_version: T.anything).returns(T::Boolean) }
61
- def resolvable?(_version)
62
- # Always return true since we don't actually attempt resolution
63
- # This is just a placeholder implementation
64
- true
72
+ sig { params(version: Gem::Version).returns(T::Boolean) }
73
+ def resolvable?(version:)
74
+ target_version = Uv::Version.new(version.to_s)
75
+ key = target_version.to_s
76
+ return T.must(@resolvable_versions[key]) if @resolvable_versions.key?(key)
77
+
78
+ @resolvable_versions[key] = resolved_version("==#{key}", target_version)&.to_s == key
65
79
  end
66
80
 
67
81
  sig { returns(T.nilable(Dependabot::Uv::Version)) }
68
82
  def lowest_resolvable_security_fix_version
69
83
  # Delegate to LatestVersionFinder which handles security advisory filtering
70
- fix_version = latest_version_finder.lowest_security_fix_version
71
- return nil if fix_version.nil?
84
+ fix_version = latest_version_finder.lowest_security_fix_version(
85
+ language_version: python_version
86
+ )
87
+ return nil unless fix_version
88
+ return nil unless resolvable?(version: fix_version)
72
89
 
73
90
  # Return the fix version cast to Uv::Version
74
91
  Uv::Version.new(fix_version.to_s)
@@ -97,6 +114,191 @@ module Dependabot
97
114
  sig { returns(T.nilable(Dependabot::Package::ReleaseCooldownOptions)) }
98
115
  attr_reader :update_cooldown
99
116
 
117
+ sig { params(requirement_string: String).returns(T.nilable(Dependabot::Uv::Version)) }
118
+ def fetch_latest_resolvable_version(requirement_string)
119
+ requirement = Uv::Requirement.new(requirement_string)
120
+ current_version = dependency.version && Uv::Version.new(dependency.version)
121
+ eligible_versions = eligible_versions(requirement, current_version)
122
+ return current_version if no_newer_eligible_version?(requirement, current_version, eligible_versions)
123
+
124
+ native_version = resolved_version(
125
+ policy_requirement(requirement_string, requirement, current_version, eligible_versions),
126
+ nil
127
+ )
128
+ if eligible_native_version?(requirement, native_version, eligible_versions) &&
129
+ resolvable?(version: T.must(native_version))
130
+ return native_version
131
+ end
132
+
133
+ current_version if current_version && requirement.satisfied_by?(current_version)
134
+ end
135
+
136
+ sig do
137
+ params(
138
+ requirement: Dependabot::Uv::Requirement,
139
+ current_version: T.nilable(Dependabot::Uv::Version),
140
+ eligible_versions: T.nilable(T::Array[Dependabot::Uv::Version])
141
+ ).returns(T::Boolean)
142
+ end
143
+ def no_newer_eligible_version?(requirement, current_version, eligible_versions)
144
+ return false unless current_version && eligible_versions
145
+
146
+ requirement.satisfied_by?(current_version) && eligible_versions.empty?
147
+ end
148
+
149
+ sig do
150
+ params(
151
+ requirement: Dependabot::Uv::Requirement,
152
+ native_version: T.nilable(Dependabot::Uv::Version),
153
+ eligible_versions: T.nilable(T::Array[Dependabot::Uv::Version])
154
+ ).returns(T::Boolean)
155
+ end
156
+ def eligible_native_version?(requirement, native_version, eligible_versions)
157
+ return false unless native_version && requirement.satisfied_by?(native_version)
158
+
159
+ !eligible_versions || eligible_versions.include?(native_version)
160
+ end
161
+
162
+ sig do
163
+ params(
164
+ requirement: Dependabot::Uv::Requirement,
165
+ current_version: T.nilable(Dependabot::Uv::Version)
166
+ ).returns(T.nilable(T::Array[Dependabot::Uv::Version]))
167
+ end
168
+ def eligible_versions(requirement, current_version)
169
+ releases = latest_version_finder.eligible_releases(
170
+ language_version: python_version
171
+ )
172
+ return unless releases
173
+
174
+ releases = Dependabot::UpdateCheckers::VersionFilters
175
+ .filter_vulnerable_versions(releases, security_advisories)
176
+ releases
177
+ .map { |release| Uv::Version.new(release.version.to_s) }
178
+ .select { |version| requirement.satisfied_by?(version) && (!current_version || version > current_version) }
179
+ end
180
+
181
+ sig do
182
+ params(
183
+ requirement_string: String,
184
+ requirement: Dependabot::Uv::Requirement,
185
+ current_version: T.nilable(Dependabot::Uv::Version),
186
+ eligible_versions: T.nilable(T::Array[Dependabot::Uv::Version])
187
+ ).returns(String)
188
+ end
189
+ def policy_requirement(requirement_string, requirement, current_version, eligible_versions)
190
+ releases = latest_version_finder.available_versions
191
+ return requirement_string unless releases && eligible_versions
192
+
193
+ excluded_versions = releases
194
+ .map { |release| Uv::Version.new(release.version.to_s) }
195
+ .select do |version|
196
+ requirement.satisfied_by?(version) &&
197
+ (!current_version || version > current_version) &&
198
+ !eligible_versions.include?(version)
199
+ end
200
+
201
+ ([requirement_string] + excluded_versions.map { |version| "!=#{version}" }).join(",")
202
+ end
203
+
204
+ sig do
205
+ params(
206
+ requirement_string: String,
207
+ target_version: T.nilable(Dependabot::Uv::Version)
208
+ )
209
+ .returns(T.nilable(Dependabot::Uv::Version))
210
+ end
211
+ def resolved_version(requirement_string, target_version)
212
+ return @resolved_versions[requirement_string] if @resolved_versions.key?(requirement_string)
213
+
214
+ @resolved_versions[requirement_string] = run_resolution(requirement_string, target_version)
215
+ end
216
+
217
+ sig do
218
+ params(
219
+ requirement_string: String,
220
+ target_version: T.nilable(Dependabot::Uv::Version)
221
+ )
222
+ .returns(T.nilable(Dependabot::Uv::Version))
223
+ end
224
+ def run_resolution(requirement_string, target_version)
225
+ updated_dependency = Dependabot::Dependency.new(
226
+ name: dependency.name,
227
+ version: nil,
228
+ previous_version: dependency.version,
229
+ requirements: [],
230
+ previous_requirements: [],
231
+ package_manager: dependency.package_manager
232
+ )
233
+
234
+ updated_files = FileUpdater::LockFileUpdater.new(
235
+ dependencies: [updated_dependency],
236
+ dependency_files: dependency_files,
237
+ credentials: credentials,
238
+ repo_contents_path: repo_contents_path,
239
+ target_requirement: requirement_string
240
+ ).updated_dependency_files
241
+
242
+ lockfile = updated_files.find { |file| file.name == "uv.lock" } || original_lockfile
243
+ resolved_locked_version(T.must(lockfile), target_version)
244
+ rescue Dependabot::DependencyFileContentNotChanged
245
+ resolved_locked_version(T.must(original_lockfile), target_version)
246
+ rescue Dependabot::UpdateNotPossible
247
+ nil
248
+ rescue Dependabot::DependencyFileNotResolvable => e
249
+ conflict = e.message.match?(FileUpdater::LockFileErrorHandler::UV_UNRESOLVABLE_REGEX) ||
250
+ e.message.include?(FileUpdater::LockFileErrorHandler::RESOLUTION_IMPOSSIBLE_ERROR)
251
+ raise unless conflict
252
+
253
+ nil
254
+ end
255
+
256
+ sig do
257
+ params(
258
+ lockfile: Dependabot::DependencyFile,
259
+ target_version: T.nilable(Dependabot::Uv::Version)
260
+ ).returns(T.nilable(Dependabot::Uv::Version))
261
+ end
262
+ def resolved_locked_version(lockfile, target_version)
263
+ original_versions = locked_versions(T.must(original_lockfile))
264
+ updated_versions = locked_versions(lockfile)
265
+ current_version = dependency.version && Uv::Version.new(dependency.version)
266
+ return nil unless current_version && original_versions.min == current_version
267
+ return current_version if updated_versions.include?(current_version)
268
+
269
+ updated_version = updated_versions.min
270
+ return target_version if target_version && updated_version == target_version
271
+ return nil if target_version
272
+
273
+ updated_version
274
+ end
275
+
276
+ sig do
277
+ params(lockfile: Dependabot::DependencyFile)
278
+ .returns(T::Array[Dependabot::Uv::Version])
279
+ end
280
+ def locked_versions(lockfile)
281
+ parsed = T.cast(TomlRB.parse(T.must(lockfile.content)), T::Hash[String, Object])
282
+ packages = T.cast(parsed["package"], T.nilable(T::Array[T::Hash[String, Object]])) || []
283
+ dependency_name = NameNormaliser.normalise(dependency.name)
284
+ versions = T.let([], T::Array[Dependabot::Uv::Version])
285
+ packages.each do |package|
286
+ package_name = T.cast(package["name"], T.nilable(String))
287
+ package_version = T.cast(package["version"], T.nilable(String))
288
+ next unless package_name && NameNormaliser.normalise(package_name) == dependency_name
289
+ next unless Uv::Version.correct?(package_version)
290
+
291
+ versions << Uv::Version.new(package_version)
292
+ end
293
+
294
+ versions
295
+ end
296
+
297
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
298
+ def original_lockfile
299
+ dependency_files.find { |file| file.name == "uv.lock" }
300
+ end
301
+
100
302
  sig { returns(LatestVersionFinder) }
101
303
  def latest_version_finder
102
304
  @latest_version_finder ||= T.let(
@@ -112,6 +314,18 @@ module Dependabot
112
314
  T.nilable(LatestVersionFinder)
113
315
  )
114
316
  end
317
+
318
+ sig { returns(String) }
319
+ def python_version
320
+ @python_version ||= T.let(
321
+ LanguageVersionManager.new(
322
+ python_requirement_parser: FileParser::PythonRequirementParser.new(
323
+ dependency_files: dependency_files
324
+ )
325
+ ).python_version,
326
+ T.nilable(String)
327
+ )
328
+ end
115
329
  end
116
330
  end
117
331
  end
@@ -213,29 +213,6 @@ module Dependabot
213
213
  )
214
214
  end
215
215
 
216
- sig { override.returns(T::Boolean) }
217
- def library?
218
- return false unless updating_pyproject?
219
- return false unless library_details
220
-
221
- return false if T.must(library_details)["name"].nil?
222
-
223
- # Hit PyPi and check whether there are details for a library with a
224
- # matching name and description
225
- index_response = Dependabot::RegistryClient.get(
226
- url: "https://pypi.org/pypi/#{normalised_name(T.must(library_details)['name'])}/json/"
227
- )
228
-
229
- return false unless index_response.status == 200
230
-
231
- pypi_info = JSON.parse(index_response.body)["info"] || {}
232
- pypi_info["summary"] == T.must(library_details)["description"]
233
- rescue Excon::Error::Timeout, Excon::Error::Socket
234
- false
235
- rescue URI::InvalidURIError
236
- false
237
- end
238
-
239
216
  sig { returns(T::Boolean) }
240
217
  def updating_pyproject?
241
218
  requirement_files.any? { |file| file.end_with?("pyproject.toml") }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-uv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.392.0
4
+ version: 0.394.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.392.0
18
+ version: 0.394.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.392.0
25
+ version: 0.394.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: dependabot-python
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 0.392.0
32
+ version: 0.394.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 0.392.0
39
+ version: 0.394.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: debug
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -183,14 +183,14 @@ dependencies:
183
183
  requirements:
184
184
  - - "~>"
185
185
  - !ruby/object:Gem::Version
186
- version: '0.22'
186
+ version: '1.1'
187
187
  type: :development
188
188
  prerelease: false
189
189
  version_requirements: !ruby/object:Gem::Requirement
190
190
  requirements:
191
191
  - - "~>"
192
192
  - !ruby/object:Gem::Version
193
- version: '0.22'
193
+ version: '1.1'
194
194
  - !ruby/object:Gem::Dependency
195
195
  name: turbo_tests
196
196
  requirement: !ruby/object:Gem::Requirement
@@ -301,7 +301,7 @@ licenses:
301
301
  - MIT
302
302
  metadata:
303
303
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
304
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.392.0
304
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.394.0
305
305
  rdoc_options: []
306
306
  require_paths:
307
307
  - lib