dependabot-uv 0.300.0 → 0.301.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: 3e306a8d52bca54571baf167605279980eeb625a9dcfbad1128a13de55270936
4
- data.tar.gz: 7bd9b6a85cf4e76675eaf401ed285acad0e6e518920e3cd344ae0074fa8a3a2a
3
+ metadata.gz: f8eea1accc31d49dda91328296fc952b9d49a1e3cfddb443772b1cd794905779
4
+ data.tar.gz: a6978d2a2679d009129333b785331ee903b19b5ef673bdba89d81a1b8d67473f
5
5
  SHA512:
6
- metadata.gz: 8a4fb9a0de4405de4e3130e78e917fc41480770e9b2053d7b368bbcfe397d202f12fde5db46edc563047322354abe87ff65a14190560f960b0c93f56e1d7a6fb
7
- data.tar.gz: 0f5f35583b805d8a5047bbea2c69db49636759c63f7afc50f0957368d99f5ae874b742ade653f6983e8c5401b34cd0d966b092c14a0ea836993a704fde5f0e63
6
+ metadata.gz: 5e5c87148fd6d1db52d0a5fe59b6855a2e0fa50396b6613cb01f7d9ef86845b3c191b5b3dd8188f245c1fc230617c44b8f0531bd03fb4f15b695fc60d2674ee5
7
+ data.tar.gz: 2802f6b46388ca8a30bebfc56cf02594eaff5cf87aed03cf0c79facf0e6f13b62b150426ea2341f636607d2e8f48e899a92724451ffca00d2397c3870ca2c01e
@@ -22,19 +22,24 @@ module Dependabot
22
22
  CHILD_REQUIREMENT_REGEX = /^-r\s?(?<path>.*\.(?:txt|in))/
23
23
  CONSTRAINT_REGEX = /^-c\s?(?<path>.*\.(?:txt|in))/
24
24
  DEPENDENCY_TYPES = %w(packages dev-packages).freeze
25
+ REQUIREMENT_FILE_PATTERNS = {
26
+ extensions: [".txt", ".in"],
27
+ filenames: ["uv.lock"]
28
+ }.freeze
29
+ MAX_FILE_SIZE = 500_000
25
30
 
26
31
  def self.required_files_in?(filenames)
27
- return true if filenames.any? { |name| name.end_with?(".txt", ".in") }
32
+ return true if filenames.any? { |name| name.end_with?(*REQUIREMENT_FILE_PATTERNS[:extensions]) }
28
33
 
29
34
  # If there is a directory of requirements return true
30
35
  return true if filenames.include?("requirements")
31
36
 
32
- # If this repo is using pyproject.toml return true
37
+ # If this repo is using pyproject.toml return true (uv.lock files require a pyproject.toml)
33
38
  filenames.include?("pyproject.toml")
34
39
  end
35
40
 
36
41
  def self.required_files_message
37
- "Repo must contain a requirements.txt, requirements.in, or pyproject.toml" \
42
+ "Repo must contain a requirements.txt, uv.lock, requirements.in, or pyproject.toml" \
38
43
  end
39
44
 
40
45
  def ecosystem_versions
@@ -69,6 +74,7 @@ module Dependabot
69
74
  fetched_files += requirements_in_files
70
75
  fetched_files += requirement_files if requirements_txt_files.any?
71
76
 
77
+ fetched_files += uv_lock_files
72
78
  fetched_files += project_files
73
79
  fetched_files << python_version_file if python_version_file
74
80
 
@@ -125,6 +131,11 @@ module Dependabot
125
131
  child_requirement_in_files
126
132
  end
127
133
 
134
+ def uv_lock_files
135
+ req_txt_and_in_files.select { |f| f.name.end_with?("uv.lock") } +
136
+ child_uv_lock_files
137
+ end
138
+
128
139
  def parsed_pyproject
129
140
  raise "No pyproject.toml" unless pyproject
130
141
 
@@ -137,18 +148,8 @@ module Dependabot
137
148
  return @req_txt_and_in_files if @req_txt_and_in_files
138
149
 
139
150
  @req_txt_and_in_files = []
140
-
141
- repo_contents
142
- .select { |f| f.type == "file" }
143
- .select { |f| f.name.end_with?(".txt", ".in") }
144
- .reject { |f| f.size > 500_000 }
145
- .map { |f| fetch_file_from_host(f.name) }
146
- .select { |f| requirements_file?(f) }
147
- .each { |f| @req_txt_and_in_files << f }
148
-
149
- repo_contents
150
- .select { |f| f.type == "dir" }
151
- .each { |f| @req_txt_and_in_files += req_files_for_dir(f) }
151
+ @req_txt_and_in_files += fetch_requirement_files_from_path
152
+ @req_txt_and_in_files += fetch_requirement_files_from_dirs
152
153
 
153
154
  @req_txt_and_in_files
154
155
  end
@@ -158,12 +159,7 @@ module Dependabot
158
159
  relative_reqs_dir =
159
160
  requirements_dir.path.gsub(%r{^/?#{Regexp.escape(dir)}/?}, "")
160
161
 
161
- repo_contents(dir: relative_reqs_dir)
162
- .select { |f| f.type == "file" }
163
- .select { |f| f.name.end_with?(".txt", ".in") }
164
- .reject { |f| f.size > 500_000 }
165
- .map { |f| fetch_file_from_host("#{relative_reqs_dir}/#{f.name}") }
166
- .select { |f| requirements_file?(f) }
162
+ fetch_requirement_files_from_path(relative_reqs_dir)
167
163
  end
168
164
 
169
165
  def child_requirement_txt_files
@@ -174,6 +170,10 @@ module Dependabot
174
170
  child_requirement_files.select { |f| f.name.end_with?(".in") }
175
171
  end
176
172
 
173
+ def child_uv_lock_files
174
+ child_requirement_files.select { |f| f.name.end_with?("uv.lock") }
175
+ end
176
+
177
177
  def child_requirement_files
178
178
  @child_requirement_files ||=
179
179
  begin
@@ -321,6 +321,36 @@ module Dependabot
321
321
  def requirements_in_file_matcher
322
322
  @requirements_in_file_matcher ||= RequiremenstFileMatcher.new(requirements_in_files)
323
323
  end
324
+
325
+ def fetch_requirement_files_from_path(path = nil)
326
+ contents = path ? repo_contents(dir: path) : repo_contents
327
+ filter_requirement_files(contents, base_path: path)
328
+ end
329
+
330
+ def fetch_requirement_files_from_dirs
331
+ repo_contents
332
+ .select { |f| f.type == "dir" }
333
+ .flat_map { |dir| req_files_for_dir(dir) }
334
+ end
335
+
336
+ def filter_requirement_files(contents, base_path: nil)
337
+ contents
338
+ .select { |f| f.type == "file" }
339
+ .select { |f| file_matches_requirement_pattern?(f.name) }
340
+ .reject { |f| f.size > MAX_FILE_SIZE }
341
+ .map { |f| fetch_file_with_path(f.name, base_path) }
342
+ .select { |f| REQUIREMENT_FILE_PATTERNS[:filenames].include?(f.name) || requirements_file?(f) }
343
+ end
344
+
345
+ def file_matches_requirement_pattern?(filename)
346
+ REQUIREMENT_FILE_PATTERNS[:extensions].any? { |ext| filename.end_with?(ext) } ||
347
+ REQUIREMENT_FILE_PATTERNS[:filenames].any?(filename)
348
+ end
349
+
350
+ def fetch_file_with_path(filename, base_path)
351
+ path = base_path ? File.join(base_path, filename) : filename
352
+ fetch_file_from_host(path)
353
+ end
324
354
  end
325
355
  end
326
356
  end
@@ -14,6 +14,7 @@ require "dependabot/uv/name_normaliser"
14
14
  require "dependabot/uv/requirements_file_matcher"
15
15
  require "dependabot/uv/language_version_manager"
16
16
  require "dependabot/uv/package_manager"
17
+ require "toml-rb"
17
18
 
18
19
  module Dependabot
19
20
  module Uv
@@ -47,6 +48,7 @@ module Dependabot
47
48
  dependency_set = DependencySet.new
48
49
 
49
50
  dependency_set += pyproject_file_dependencies if pyproject
51
+ dependency_set += uv_lock_file_dependencies
50
52
  dependency_set += requirement_dependencies if requirement_files.any?
51
53
 
52
54
  dependency_set.dependencies
@@ -64,6 +66,12 @@ module Dependabot
64
66
  )
65
67
  end
66
68
 
69
+ # Normalize dependency names to match the PyPI index normalization
70
+ sig { params(name: String, extras: T::Array[String]).returns(String) }
71
+ def self.normalize_dependency_name(name, extras = [])
72
+ NameNormaliser.normalise_including_extras(name, extras)
73
+ end
74
+
67
75
  private
68
76
 
69
77
  sig { returns(LanguageVersionManager) }
@@ -164,6 +172,36 @@ module Dependabot
164
172
  dependency_files.select { |f| f.name.end_with?(".txt", ".in") }
165
173
  end
166
174
 
175
+ sig { returns(T::Array[DependencyFile]) }
176
+ def uv_lock_files
177
+ dependency_files.select { |f| f.name == "uv.lock" }
178
+ end
179
+
180
+ sig { returns(DependencySet) }
181
+ def uv_lock_file_dependencies
182
+ dependency_set = DependencySet.new
183
+
184
+ uv_lock_files.each do |file|
185
+ lockfile_content = TomlRB.parse(file.content)
186
+ packages = lockfile_content.fetch("package", [])
187
+
188
+ packages.each do |package_data|
189
+ next unless package_data.is_a?(Hash) && package_data["name"] && package_data["version"]
190
+
191
+ dependency_set << Dependency.new(
192
+ name: normalised_name(package_data["name"]),
193
+ version: package_data["version"],
194
+ requirements: [], # Lock files don't contain requirements
195
+ package_manager: "uv"
196
+ )
197
+ end
198
+ rescue StandardError => e
199
+ Dependabot.logger.warn("Error parsing uv.lock: #{e.message}")
200
+ end
201
+
202
+ dependency_set
203
+ end
204
+
167
205
  sig { returns(DependencySet) }
168
206
  def pyproject_file_dependencies
169
207
  @pyproject_file_dependencies ||= T.let(PyprojectFilesParser.new(dependency_files:
@@ -341,7 +379,7 @@ module Dependabot
341
379
 
342
380
  sig { params(name: String, extras: T::Array[String]).returns(String) }
343
381
  def normalised_name(name, extras = [])
344
- NameNormaliser.normalise_including_extras(name, extras)
382
+ FileParser.normalize_dependency_name(name, extras)
345
383
  end
346
384
 
347
385
  sig { override.returns(T.untyped) }
@@ -0,0 +1,392 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "toml-rb"
5
+ require "open3"
6
+ require "dependabot/dependency"
7
+ require "dependabot/shared_helpers"
8
+ require "dependabot/uv/language_version_manager"
9
+ require "dependabot/uv/version"
10
+ require "dependabot/uv/requirement"
11
+ require "dependabot/uv/file_parser/python_requirement_parser"
12
+ require "dependabot/uv/file_updater"
13
+ require "dependabot/uv/native_helpers"
14
+ require "dependabot/uv/name_normaliser"
15
+
16
+ module Dependabot
17
+ module Uv
18
+ class FileUpdater
19
+ class LockFileUpdater
20
+ require_relative "pyproject_preparer"
21
+
22
+ attr_reader :dependencies
23
+ attr_reader :dependency_files
24
+ attr_reader :credentials
25
+ attr_reader :index_urls
26
+
27
+ def initialize(dependencies:, dependency_files:, credentials:, index_urls: nil)
28
+ @dependencies = dependencies
29
+ @dependency_files = dependency_files
30
+ @credentials = credentials
31
+ @index_urls = index_urls
32
+ end
33
+
34
+ def updated_dependency_files
35
+ @updated_dependency_files ||= fetch_updated_dependency_files
36
+ end
37
+
38
+ private
39
+
40
+ def dependency
41
+ # For now, we'll only ever be updating a single dependency
42
+ dependencies.first
43
+ end
44
+
45
+ def fetch_updated_dependency_files
46
+ updated_files = []
47
+
48
+ if file_changed?(pyproject)
49
+ updated_files <<
50
+ updated_file(
51
+ file: pyproject,
52
+ content: updated_pyproject_content
53
+ )
54
+ end
55
+
56
+ if lockfile
57
+ # Use updated_lockfile_content which might raise if the lockfile doesn't change
58
+ new_content = updated_lockfile_content
59
+ raise "Expected lockfile to change!" if lockfile.content == new_content
60
+
61
+ updated_files << updated_file(file: lockfile, content: new_content)
62
+ end
63
+
64
+ updated_files
65
+ end
66
+
67
+ def updated_pyproject_content
68
+ content = pyproject.content
69
+ return content unless file_changed?(pyproject)
70
+
71
+ updated_content = content.dup
72
+
73
+ dependency.requirements.zip(dependency.previous_requirements).each do |new_r, old_r|
74
+ next unless new_r[:file] == pyproject.name && old_r[:file] == pyproject.name
75
+
76
+ updated_content = replace_dep(dependency, updated_content, new_r, old_r)
77
+ end
78
+
79
+ raise DependencyFileContentNotChanged, "Content did not change!" if content == updated_content
80
+
81
+ updated_content
82
+ end
83
+
84
+ def replace_dep(dep, content, new_r, old_r)
85
+ new_req = new_r[:requirement]
86
+ old_req = old_r[:requirement]
87
+
88
+ declaration_regex = declaration_regex(dep, old_r)
89
+ declaration_match = content.match(declaration_regex)
90
+ if declaration_match
91
+ declaration = declaration_match[:declaration]
92
+ new_declaration = declaration.sub(old_req, new_req)
93
+ content.sub(declaration, new_declaration)
94
+ else
95
+ content
96
+ end
97
+ end
98
+
99
+ def updated_lockfile_content
100
+ @updated_lockfile_content ||=
101
+ begin
102
+ original_content = lockfile.content
103
+ # Extract the original requires-python value to preserve it
104
+ original_requires_python = original_content
105
+ .match(/requires-python\s*=\s*["']([^"']+)["']/)&.captures&.first
106
+
107
+ # Use the original Python version requirement for the update if one exists
108
+ with_original_python_version(original_requires_python) do
109
+ new_lockfile = updated_lockfile_content_for(prepared_pyproject)
110
+
111
+ # Use direct string replacement to preserve the exact format
112
+ # Match the dependency section and update only the version
113
+ dependency_section_pattern = /
114
+ (\[\[package\]\]\s*\n
115
+ .*?name\s*=\s*["']#{Regexp.escape(dependency.name)}["']\s*\n
116
+ .*?)
117
+ (version\s*=\s*["'][^"']+["'])
118
+ (.*?)
119
+ (\[\[package\]\]|\z)
120
+ /xm
121
+
122
+ result = original_content.sub(dependency_section_pattern) do
123
+ section_start = Regexp.last_match(1)
124
+ version_line = "version = \"#{dependency.version}\""
125
+ section_end = Regexp.last_match(3)
126
+ next_section_or_end = Regexp.last_match(4)
127
+
128
+ "#{section_start}#{version_line}#{section_end}#{next_section_or_end}"
129
+ end
130
+
131
+ # If the content didn't change and we expect it to, something went wrong
132
+ if result == original_content
133
+ Dependabot.logger.warn("Package section not found for #{dependency.name}, falling back to raw update")
134
+ result = new_lockfile
135
+ end
136
+
137
+ # Restore the original requires-python if it exists
138
+ if original_requires_python
139
+ result = result.gsub(/requires-python\s*=\s*["'][^"']+["']/,
140
+ "requires-python = \"#{original_requires_python}\"")
141
+ end
142
+
143
+ result
144
+ end
145
+ end
146
+ end
147
+
148
+ # Helper method to temporarily override Python version during operations
149
+ def with_original_python_version(original_requires_python)
150
+ if original_requires_python
151
+ original_python_version = @original_python_version
152
+ @original_python_version = original_requires_python
153
+ result = yield
154
+ @original_python_version = original_python_version
155
+ result
156
+ else
157
+ yield
158
+ end
159
+ end
160
+
161
+ def prepared_pyproject
162
+ @prepared_pyproject ||=
163
+ begin
164
+ content = updated_pyproject_content
165
+ content = sanitize(content)
166
+ content = freeze_other_dependencies(content)
167
+ content = update_python_requirement(content)
168
+ content
169
+ end
170
+ end
171
+
172
+ def freeze_other_dependencies(pyproject_content)
173
+ PyprojectPreparer
174
+ .new(pyproject_content: pyproject_content, lockfile: lockfile)
175
+ .freeze_top_level_dependencies_except(dependencies)
176
+ end
177
+
178
+ def update_python_requirement(pyproject_content)
179
+ PyprojectPreparer
180
+ .new(pyproject_content: pyproject_content)
181
+ .update_python_requirement(language_version_manager.python_version)
182
+ end
183
+
184
+ def sanitize(pyproject_content)
185
+ PyprojectPreparer
186
+ .new(pyproject_content: pyproject_content)
187
+ .sanitize
188
+ end
189
+
190
+ def updated_lockfile_content_for(pyproject_content)
191
+ SharedHelpers.in_a_temporary_directory do
192
+ SharedHelpers.with_git_configured(credentials: credentials) do
193
+ write_temporary_dependency_files(pyproject_content)
194
+
195
+ # Install Python before writing .python-version to make sure we use a version that's available
196
+ language_version_manager.install_required_python
197
+
198
+ # Determine the Python version to use after installation
199
+ python_version = determine_python_version
200
+
201
+ # Now write the .python-version file with a version we know is installed
202
+ File.write(".python-version", python_version)
203
+
204
+ run_update_command
205
+
206
+ File.read("uv.lock")
207
+ end
208
+ end
209
+ end
210
+
211
+ def run_update_command
212
+ command = "pyenv exec uv lock --upgrade-package #{dependency.name}"
213
+ fingerprint = "pyenv exec uv lock --upgrade-package <dependency_name>"
214
+
215
+ run_command(command, fingerprint:)
216
+ end
217
+
218
+ def run_command(command, fingerprint: nil)
219
+ SharedHelpers.run_shell_command(command, fingerprint: fingerprint)
220
+ end
221
+
222
+ def write_temporary_dependency_files(pyproject_content)
223
+ dependency_files.each do |file|
224
+ path = file.name
225
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
226
+ File.write(path, file.content)
227
+ end
228
+
229
+ # Only write the .python-version file after the language version manager has
230
+ # installed the required Python version to ensure it's available
231
+ # Overwrite the pyproject with updated content
232
+ File.write("pyproject.toml", pyproject_content)
233
+ end
234
+
235
+ def determine_python_version
236
+ # Check available Python versions through pyenv
237
+ available_versions = nil
238
+ begin
239
+ available_versions = SharedHelpers.run_shell_command("pyenv versions --bare")
240
+ .split("\n")
241
+ .map(&:strip)
242
+ .reject(&:empty?)
243
+ rescue StandardError => e
244
+ Dependabot.logger.warn("Error checking available Python versions: #{e}")
245
+ end
246
+
247
+ # Try to find the closest match for our priority order
248
+ preferred_version = find_preferred_version(available_versions)
249
+
250
+ if preferred_version
251
+ # Just return the major.minor version string
252
+ preferred_version.match(/^(\d+\.\d+)/)[1]
253
+ else
254
+ # If all else fails, use "system" which should work with whatever Python is available
255
+ "system"
256
+ end
257
+ end
258
+
259
+ def find_preferred_version(available_versions)
260
+ return nil unless available_versions&.any?
261
+
262
+ # Try each strategy in order of preference
263
+ try_version_from_file(available_versions) ||
264
+ try_version_from_requires_python(available_versions) ||
265
+ try_highest_python3_version(available_versions)
266
+ end
267
+
268
+ def try_version_from_file(available_versions)
269
+ python_version_file = dependency_files.find { |f| f.name == ".python-version" }
270
+ return nil unless python_version_file && !python_version_file.content.strip.empty?
271
+
272
+ requested_version = python_version_file.content.strip
273
+ return requested_version if version_available?(available_versions, requested_version)
274
+
275
+ Dependabot.logger.info("Python version #{requested_version} from .python-version not available")
276
+ nil
277
+ end
278
+
279
+ def try_version_from_requires_python(available_versions)
280
+ return nil unless @original_python_version
281
+
282
+ version_match = @original_python_version.match(/(\d+\.\d+)/)
283
+ return nil unless version_match
284
+
285
+ requested_version = version_match[1]
286
+ return requested_version if version_available?(available_versions, requested_version)
287
+
288
+ Dependabot.logger.info("Python version #{requested_version} from requires-python not available")
289
+ nil
290
+ end
291
+
292
+ def try_highest_python3_version(available_versions)
293
+ python3_versions = available_versions
294
+ .select { |v| v.match(/^3\.\d+/) }
295
+ .sort_by { |v| Gem::Version.new(v.match(/^(\d+\.\d+)/)[1]) }
296
+ .reverse
297
+
298
+ python3_versions.first # returns nil if array is empty
299
+ end
300
+
301
+ def version_available?(available_versions, requested_version)
302
+ # Check if the exact version or a version with the same major.minor is available
303
+ available_versions.any? do |v|
304
+ v == requested_version || v.start_with?("#{requested_version}.")
305
+ end
306
+ end
307
+
308
+ def sanitize_env_name(url)
309
+ url.gsub(%r{^https?://}, "").gsub(/[^a-zA-Z0-9]/, "_").upcase
310
+ end
311
+
312
+ def declaration_regex(dep, old_req)
313
+ escaped_name = Regexp.escape(dep.name)
314
+ # Extract the requirement operator and version
315
+ operator = old_req.fetch(:requirement).match(/^(.+?)[0-9]/)&.captures&.first
316
+ # Escape special regex characters in the operator
317
+ escaped_operator = Regexp.escape(operator) if operator
318
+
319
+ # Match various formats of dependency declarations:
320
+ # 1. "dependency==1.0.0" (with quotes around the entire string)
321
+ # 2. dependency==1.0.0 (without quotes)
322
+ # The declaration should only include the package name, operator, and version
323
+ # without the enclosing quotes
324
+ /
325
+ ["']?(?<declaration>#{escaped_name}\s*#{escaped_operator}[\d\.\*]+)["']?
326
+ /x
327
+ end
328
+
329
+ def escape(name)
330
+ Regexp.escape(name).gsub("\\-", "[-_.]")
331
+ end
332
+
333
+ def file_changed?(file)
334
+ return false unless file
335
+
336
+ dependencies.any? do |dep|
337
+ dep.requirements.any? { |r| r[:file] == file.name } &&
338
+ requirement_changed?(file, dep)
339
+ end
340
+ end
341
+
342
+ def requirement_changed?(file, dependency)
343
+ changed_requirements =
344
+ dependency.requirements - dependency.previous_requirements
345
+
346
+ changed_requirements.any? { |f| f[:file] == file.name }
347
+ end
348
+
349
+ def updated_file(file:, content:)
350
+ updated_file = file.dup
351
+ updated_file.content = content
352
+ updated_file
353
+ end
354
+
355
+ def normalise(name)
356
+ NameNormaliser.normalise(name)
357
+ end
358
+
359
+ def python_requirement_parser
360
+ @python_requirement_parser ||=
361
+ FileParser::PythonRequirementParser.new(
362
+ dependency_files: dependency_files
363
+ )
364
+ end
365
+
366
+ def language_version_manager
367
+ @language_version_manager ||=
368
+ LanguageVersionManager.new(
369
+ python_requirement_parser: python_requirement_parser
370
+ )
371
+ end
372
+
373
+ def pyproject
374
+ @pyproject ||=
375
+ dependency_files.find { |f| f.name == "pyproject.toml" }
376
+ end
377
+
378
+ def lockfile
379
+ @lockfile ||= uv_lock
380
+ end
381
+
382
+ def python_helper_path
383
+ NativeHelpers.python_helper_path
384
+ end
385
+
386
+ def uv_lock
387
+ dependency_files.find { |f| f.name == "uv.lock" }
388
+ end
389
+ end
390
+ end
391
+ end
392
+ end
@@ -19,104 +19,124 @@ module Dependabot
19
19
  @lockfile = lockfile
20
20
  end
21
21
 
22
- # For hosted Dependabot token will be nil since the credentials aren't present.
23
- # This is for those running Dependabot themselves and for dry-run.
24
- def add_auth_env_vars(credentials)
25
- TomlRB.parse(@pyproject_content).dig("tool", "poetry", "source")&.each do |source|
26
- cred = credentials&.find { |c| c["index-url"] == source["url"] }
27
- next unless cred
28
-
29
- token = cred.fetch("token", nil)
30
- next unless token && token.count(":") == 1
31
-
32
- arr = token.split(":")
33
- # https://python-poetry.org/docs/configuration/#using-environment-variables
34
- name = source["name"]&.upcase&.gsub(/\W/, "_")
35
- ENV["POETRY_HTTP_BASIC_#{name}_USERNAME"] = arr[0]
36
- ENV["POETRY_HTTP_BASIC_#{name}_PASSWORD"] = arr[1]
22
+ def freeze_top_level_dependencies_except(dependencies_to_update)
23
+ return @pyproject_content unless lockfile
24
+
25
+ pyproject_object = TomlRB.parse(@pyproject_content)
26
+ deps_to_update_names = dependencies_to_update.map(&:name)
27
+
28
+ if pyproject_object["project"]&.key?("dependencies")
29
+ locked_deps = parsed_lockfile_dependencies || {}
30
+
31
+ pyproject_object["project"]["dependencies"] =
32
+ pyproject_object["project"]["dependencies"].map do |dep_string|
33
+ freeze_dependency(dep_string, deps_to_update_names, locked_deps)
34
+ end
37
35
  end
36
+
37
+ TomlRB.dump(pyproject_object)
38
38
  end
39
39
 
40
- def update_python_requirement(requirement)
40
+ def update_python_requirement(python_version)
41
+ return @pyproject_content unless python_version
42
+
41
43
  pyproject_object = TomlRB.parse(@pyproject_content)
42
- if (python_specification = pyproject_object.dig("tool", "poetry", "dependencies", "python"))
43
- python_req = Uv::Requirement.new(python_specification)
44
- unless python_req.satisfied_by?(requirement)
45
- pyproject_object["tool"]["poetry"]["dependencies"]["python"] = "~#{requirement}"
46
- end
44
+
45
+ if pyproject_object["project"]&.key?("requires-python")
46
+ pyproject_object["project"]["requires-python"] = ">=#{python_version}"
47
47
  end
48
+
48
49
  TomlRB.dump(pyproject_object)
49
50
  end
50
51
 
51
- def sanitize
52
- # {{ name }} syntax not allowed
53
- pyproject_content
54
- .gsub(/\{\{.*?\}\}/, "something")
55
- .gsub('#{', "{")
56
- end
52
+ def add_auth_env_vars(credentials)
53
+ return unless credentials
57
54
 
58
- # rubocop:disable Metrics/PerceivedComplexity
59
- # rubocop:disable Metrics/AbcSize
60
- def freeze_top_level_dependencies_except(dependencies)
61
- return pyproject_content unless lockfile
62
-
63
- pyproject_object = TomlRB.parse(pyproject_content)
64
- poetry_object = pyproject_object["tool"]["poetry"]
65
- excluded_names = dependencies.map(&:name) + ["python"]
66
-
67
- Dependabot::Uv::FileParser::PyprojectFilesParser::POETRY_DEPENDENCY_TYPES.each do |key|
68
- next unless poetry_object[key]
69
-
70
- source_types = %w(directory file url)
71
- poetry_object.fetch(key).each do |dep_name, _|
72
- next if excluded_names.include?(normalise(dep_name))
73
-
74
- locked_details = locked_details(dep_name)
75
-
76
- next unless (locked_version = locked_details&.fetch("version"))
77
-
78
- next if source_types.include?(locked_details&.dig("source", "type"))
79
-
80
- if locked_details&.dig("source", "type") == "git"
81
- poetry_object[key][dep_name] = {
82
- "git" => locked_details&.dig("source", "url"),
83
- "rev" => locked_details&.dig("source", "reference")
84
- }
85
- subdirectory = locked_details&.dig("source", "subdirectory")
86
- poetry_object[key][dep_name]["subdirectory"] = subdirectory if subdirectory
87
- elsif poetry_object[key][dep_name].is_a?(Hash)
88
- poetry_object[key][dep_name]["version"] = locked_version
89
- elsif poetry_object[key][dep_name].is_a?(Array)
90
- # if it has multiple-constraints, locking to a single version is
91
- # going to result in a bad lockfile, ignore
92
- next
93
- else
94
- poetry_object[key][dep_name] = locked_version
95
- end
96
- end
55
+ credentials.each do |credential|
56
+ next unless credential["type"] == "python_index"
57
+
58
+ token = credential["token"]
59
+ index_url = credential["index-url"]
60
+
61
+ next unless token && index_url
62
+
63
+ # Set environment variables for uv auth
64
+ ENV["UV_INDEX_URL_TOKEN_#{sanitize_env_name(index_url)}"] = token
65
+
66
+ # Also set pip-style credentials for compatibility
67
+ ENV["PIP_INDEX_URL"] ||= "https://#{token}@#{index_url.gsub(%r{^https?://}, '')}"
97
68
  end
69
+ end
98
70
 
99
- TomlRB.dump(pyproject_object)
71
+ def sanitize
72
+ # No special sanitization needed for UV files at this point
73
+ @pyproject_content
100
74
  end
101
- # rubocop:enable Metrics/AbcSize
102
- # rubocop:enable Metrics/PerceivedComplexity
103
75
 
104
76
  private
105
77
 
106
- attr_reader :pyproject_content
107
78
  attr_reader :lockfile
108
79
 
109
- def locked_details(dep_name)
110
- parsed_lockfile.fetch("package")
111
- .find { |d| d["name"] == normalise(dep_name) }
80
+ def parsed_lockfile
81
+ @parsed_lockfile ||= lockfile ? parse_lockfile(lockfile.content) : {}
112
82
  end
113
83
 
114
- def normalise(name)
115
- NameNormaliser.normalise(name)
84
+ def parse_lockfile(content)
85
+ TomlRB.parse(content)
86
+ rescue TomlRB::ParseError
87
+ {} # Return empty hash if parsing fails
116
88
  end
117
89
 
118
- def parsed_lockfile
119
- @parsed_lockfile ||= TomlRB.parse(lockfile.content)
90
+ def parsed_lockfile_dependencies
91
+ return {} unless lockfile
92
+
93
+ deps = {}
94
+ parsed = parsed_lockfile
95
+
96
+ # Handle UV lock format (version 1)
97
+ if parsed["version"] == 1 && parsed["package"].is_a?(Array)
98
+ parsed["package"].each do |pkg|
99
+ next unless pkg["name"] && pkg["version"]
100
+
101
+ deps[pkg["name"]] = { "version" => pkg["version"] }
102
+ end
103
+ # Handle traditional Poetry-style lock format
104
+ elsif parsed["dependencies"]
105
+ deps = parsed["dependencies"]
106
+ end
107
+
108
+ deps
109
+ end
110
+
111
+ def locked_version_for_dep(locked_deps, dep_name)
112
+ locked_deps.each do |name, details|
113
+ next unless Uv::FileParser.normalize_dependency_name(name) == dep_name
114
+ return details["version"] if details.is_a?(Hash) && details["version"]
115
+ end
116
+ nil
117
+ end
118
+
119
+ def sanitize_env_name(url)
120
+ url.gsub(%r{^https?://}, "").gsub(/[^a-zA-Z0-9]/, "_").upcase
121
+ end
122
+
123
+ def freeze_dependency(dep_string, deps_to_update_names, locked_deps)
124
+ package_name = dep_string.split(/[=>~<\[]/).first.strip
125
+ normalized_name = Uv::FileParser.normalize_dependency_name(package_name)
126
+
127
+ return dep_string if deps_to_update_names.include?(normalized_name)
128
+
129
+ version = locked_version_for_dep(locked_deps, normalized_name)
130
+ return dep_string unless version
131
+
132
+ if dep_string.include?("=") || dep_string.include?(">") ||
133
+ dep_string.include?("<") || dep_string.include?("~")
134
+ # Replace version constraint with exact version
135
+ dep_string.sub(/[=>~<\[].*$/, "==#{version}")
136
+ else
137
+ # Simple dependency, just append version
138
+ "#{dep_string}==#{version}"
139
+ end
120
140
  end
121
141
  end
122
142
  end
@@ -13,6 +13,7 @@ module Dependabot
13
13
  extend T::Sig
14
14
 
15
15
  require_relative "file_updater/compile_file_updater"
16
+ require_relative "file_updater/lock_file_updater"
16
17
  require_relative "file_updater/requirement_file_updater"
17
18
 
18
19
  sig { override.returns(T::Array[Regexp]) }
@@ -20,13 +21,15 @@ module Dependabot
20
21
  [
21
22
  /^.*\.txt$/, # Match any .txt files (e.g., requirements.txt) at any level
22
23
  /^.*\.in$/, # Match any .in files at any level
23
- /^.*pyproject\.toml$/ # Match pyproject.toml at any level
24
+ /^.*pyproject\.toml$/, # Match pyproject.toml at any level
25
+ /^.*uv\.lock$/ # Match uv.lock at any level
24
26
  ]
25
27
  end
26
28
 
27
29
  sig { override.returns(T::Array[DependencyFile]) }
28
30
  def updated_dependency_files
29
31
  updated_files = updated_pip_compile_based_files
32
+ updated_files += updated_uv_lock_files
30
33
 
31
34
  if updated_files.none? ||
32
35
  updated_files.sort_by(&:name) == dependency_files.sort_by(&:name)
@@ -65,6 +68,16 @@ module Dependabot
65
68
  ).updated_dependency_files
66
69
  end
67
70
 
71
+ sig { returns(T::Array[DependencyFile]) }
72
+ def updated_uv_lock_files
73
+ LockFileUpdater.new(
74
+ dependencies: dependencies,
75
+ dependency_files: dependency_files,
76
+ credentials: credentials,
77
+ index_urls: pip_compile_index_urls
78
+ ).updated_dependency_files
79
+ end
80
+
68
81
  sig { returns(T::Array[String]) }
69
82
  def pip_compile_index_urls
70
83
  if credentials.any?(&:replaces_base?)
@@ -0,0 +1,48 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/uv/version"
5
+ require "dependabot/uv/requirement"
6
+ require "dependabot/uv/update_checker"
7
+
8
+ module Dependabot
9
+ module Uv
10
+ class UpdateChecker
11
+ class LockFileResolver
12
+ def initialize(dependency:, dependency_files:, credentials:, repo_contents_path: nil)
13
+ @dependency = dependency
14
+ @dependency_files = dependency_files
15
+ @credentials = credentials
16
+ @repo_contents_path = repo_contents_path
17
+ end
18
+
19
+ def latest_resolvable_version(requirement:)
20
+ return nil unless requirement
21
+
22
+ req = Uv::Requirement.new(requirement)
23
+
24
+ # Get the version from the dependency if available
25
+ version_from_dependency = dependency.version && Uv::Version.new(dependency.version)
26
+ return version_from_dependency if version_from_dependency && req.satisfied_by?(version_from_dependency)
27
+
28
+ nil
29
+ end
30
+
31
+ def resolvable?(*)
32
+ true
33
+ end
34
+
35
+ def lowest_resolvable_security_fix_version
36
+ nil
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :dependency
42
+ attr_reader :dependency_files
43
+ attr_reader :credentials
44
+ attr_reader :repo_contents_path
45
+ end
46
+ end
47
+ end
48
+ end
@@ -21,6 +21,7 @@ module Dependabot
21
21
  require_relative "update_checker/pip_version_resolver"
22
22
  require_relative "update_checker/requirements_updater"
23
23
  require_relative "update_checker/latest_version_finder"
24
+ require_relative "update_checker/lock_file_resolver"
24
25
 
25
26
  MAIN_PYPI_INDEXES = %w(
26
27
  https://pypi.python.org/simple/
@@ -118,6 +119,7 @@ module Dependabot
118
119
  case resolver_type
119
120
  when :pip_compile then pip_compile_version_resolver
120
121
  when :requirements then pip_version_resolver
122
+ when :lock_file then lock_file_resolver
121
123
  else raise "Unexpected resolver type #{resolver_type}"
122
124
  end
123
125
  end
@@ -134,6 +136,7 @@ module Dependabot
134
136
  # which resolver to use based on the filename of its requirements
135
137
  return :requirements if updating_pyproject?
136
138
  return :pip_compile if updating_in_file?
139
+ return :lock_file if updating_uv_lock?
137
140
 
138
141
  if dependency.version && !exact_requirement?(reqs)
139
142
  subdependency_resolver
@@ -171,6 +174,10 @@ module Dependabot
171
174
  )
172
175
  end
173
176
 
177
+ def lock_file_resolver
178
+ @lock_file_resolver ||= LockFileResolver.new(**resolver_args)
179
+ end
180
+
174
181
  def resolver_args
175
182
  {
176
183
  dependency: dependency,
@@ -187,7 +194,7 @@ module Dependabot
187
194
  requirement = reqs.find do |r|
188
195
  file = r[:file]
189
196
 
190
- file == "Pipfile" || file == "pyproject.toml" || file.end_with?(".in") || file.end_with?(".txt")
197
+ file == "uv.lock" || file == "pyproject.toml" || file.end_with?(".in") || file.end_with?(".txt")
191
198
  end
192
199
 
193
200
  requirement&.fetch(:requirement)
@@ -267,6 +274,10 @@ module Dependabot
267
274
  requirement_files.any? { |f| f.end_with?(".in") }
268
275
  end
269
276
 
277
+ def updating_uv_lock?
278
+ requirement_files.any?("uv.lock")
279
+ end
280
+
270
281
  def requirements_text_file?
271
282
  requirement_files.any? { |f| f.end_with?("requirements.txt") }
272
283
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-uv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.300.0
4
+ version: 0.301.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-03-06 00:00:00.000000000 Z
11
+ date: 2025-03-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dependabot-common
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - '='
18
18
  - !ruby/object:Gem::Version
19
- version: 0.300.0
19
+ version: 0.301.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - '='
25
25
  - !ruby/object:Gem::Version
26
- version: 0.300.0
26
+ version: 0.301.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: debug
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -257,6 +257,7 @@ files:
257
257
  - lib/dependabot/uv/file_parser/setup_file_parser.rb
258
258
  - lib/dependabot/uv/file_updater.rb
259
259
  - lib/dependabot/uv/file_updater/compile_file_updater.rb
260
+ - lib/dependabot/uv/file_updater/lock_file_updater.rb
260
261
  - lib/dependabot/uv/file_updater/pyproject_preparer.rb
261
262
  - lib/dependabot/uv/file_updater/requirement_file_updater.rb
262
263
  - lib/dependabot/uv/file_updater/requirement_replacer.rb
@@ -273,6 +274,7 @@ files:
273
274
  - lib/dependabot/uv/update_checker.rb
274
275
  - lib/dependabot/uv/update_checker/index_finder.rb
275
276
  - lib/dependabot/uv/update_checker/latest_version_finder.rb
277
+ - lib/dependabot/uv/update_checker/lock_file_resolver.rb
276
278
  - lib/dependabot/uv/update_checker/pip_compile_version_resolver.rb
277
279
  - lib/dependabot/uv/update_checker/pip_version_resolver.rb
278
280
  - lib/dependabot/uv/update_checker/requirements_updater.rb
@@ -282,7 +284,7 @@ licenses:
282
284
  - MIT
283
285
  metadata:
284
286
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
285
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.300.0
287
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.301.0
286
288
  post_install_message:
287
289
  rdoc_options: []
288
290
  require_paths: