dependabot-uv 0.300.0 → 0.301.1

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.
@@ -0,0 +1,488 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+ require "cgi"
7
+ require "excon"
8
+ require "nokogiri"
9
+ require "sorbet-runtime"
10
+ require "dependabot/registry_client"
11
+ require "dependabot/uv/name_normaliser"
12
+ require "dependabot/package/package_release"
13
+ require "dependabot/package/package_details"
14
+ require "dependabot/uv/package/package_registry_finder"
15
+
16
+ # Stores metadata for a package, including all its available versions
17
+ module Dependabot
18
+ module Uv
19
+ module Package
20
+ CREDENTIALS_USERNAME = "username"
21
+ CREDENTIALS_PASSWORD = "password"
22
+
23
+ APPLICATION_JSON = "application/json"
24
+ APPLICATION_TEXT = "text/html"
25
+ CPYTHON = "cpython"
26
+ PYTHON = "python"
27
+ UNKNOWN = "unknown"
28
+
29
+ MAIN_PYPI_INDEXES = %w(
30
+ https://pypi.python.org/simple/
31
+ https://pypi.org/simple/
32
+ ).freeze
33
+ VERSION_REGEX = /[0-9]+(?:\.[A-Za-z0-9\-_]+)*/
34
+
35
+ class PackageDetailsFetcher
36
+ extend T::Sig
37
+
38
+ sig do
39
+ params(
40
+ dependency: Dependabot::Dependency,
41
+ dependency_files: T::Array[Dependabot::DependencyFile],
42
+ credentials: T::Array[Dependabot::Credential]
43
+ ).void
44
+ end
45
+ def initialize(
46
+ dependency:,
47
+ dependency_files:,
48
+ credentials:
49
+ )
50
+ @dependency = dependency
51
+ @dependency_files = dependency_files
52
+ @credentials = credentials
53
+
54
+ @registry_urls = T.let(nil, T.nilable(T::Array[String]))
55
+ end
56
+
57
+ sig { returns(Dependabot::Dependency) }
58
+ attr_reader :dependency
59
+
60
+ sig { returns(T::Array[T.untyped]) }
61
+ attr_reader :dependency_files
62
+
63
+ sig { returns(T::Array[T.untyped]) }
64
+ attr_reader :credentials
65
+
66
+ sig { returns(Dependabot::Package::PackageDetails) }
67
+ def fetch
68
+ package_releases = registry_urls
69
+ .select { |index_url| validate_index(index_url) } # Ensure only valid URLs
70
+ .flat_map do |index_url|
71
+ fetch_from_registry(index_url) || [] # Ensure it always returns an array
72
+ rescue Excon::Error::Timeout, Excon::Error::Socket
73
+ raise if MAIN_PYPI_INDEXES.include?(index_url)
74
+
75
+ raise PrivateSourceTimedOut, sanitized_url(index_url)
76
+ rescue URI::InvalidURIError
77
+ raise DependencyFileNotResolvable, "Invalid URL: #{sanitized_url(index_url)}"
78
+ end
79
+
80
+ Dependabot::Package::PackageDetails.new(
81
+ dependency: dependency,
82
+ releases: package_releases.reverse.uniq(&:version)
83
+ )
84
+ end
85
+
86
+ private
87
+
88
+ sig do
89
+ params(index_url: String)
90
+ .returns(T.nilable(T::Array[Dependabot::Package::PackageRelease]))
91
+ end
92
+ def fetch_from_registry(index_url)
93
+ if Dependabot::Experiments.enabled?(:enable_cooldown_for_uv)
94
+ metadata = fetch_from_json_registry(index_url)
95
+
96
+ return metadata if metadata&.any?
97
+
98
+ Dependabot.logger.warn("No valid versions found via JSON API. Falling back to HTML.")
99
+ end
100
+ fetch_from_html_registry(index_url)
101
+ rescue StandardError => e
102
+ Dependabot.logger.warn("Unexpected error in JSON fetch: #{e.message}. Falling back to HTML.")
103
+ fetch_from_html_registry(index_url)
104
+ end
105
+
106
+ # Example JSON Response Format:
107
+ #
108
+ # {
109
+ # "info": {
110
+ # "name": "requests",
111
+ # "summary": "Python HTTP for Humans.",
112
+ # "author": "Kenneth Reitz",
113
+ # "license": "Apache-2.0"
114
+ # },
115
+ # "releases": {
116
+ # "2.32.3": [
117
+ # {
118
+ # "filename": "requests-2.32.3-py3-none-any.whl",
119
+ # "version": "2.32.3",
120
+ # "requires_python": ">=3.8",
121
+ # "yanked": false,
122
+ # "url": "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl"
123
+ # },
124
+ # {
125
+ # "filename": "requests-2.32.3.tar.gz",
126
+ # "version": "2.32.3",
127
+ # "requires_python": ">=3.8",
128
+ # "yanked": false,
129
+ # "url": "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz"
130
+ # }
131
+ # ],
132
+ # "2.27.0": [
133
+ # {
134
+ # "filename": "requests-2.27.0-py2.py3-none-any.whl",
135
+ # "version": "2.27.0",
136
+ # "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*",
137
+ # "yanked": false,
138
+ # "url": "https://files.pythonhosted.org/packages/47/01/f420e7add78110940639a958e5af0e3f8e07a8a8b62049bac55ee117aa91/requests-2.27.0-py2.py3-none-any.whl"
139
+ # },
140
+ # {
141
+ # "filename": "requests-2.27.0.tar.gz",
142
+ # "version": "2.27.0",
143
+ # "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*",
144
+ # "yanked": false,
145
+ # "url": "https://files.pythonhosted.org/packages/c0/e3/826e27b942352a74b656e8f58b4dc7ed9495ce2d4eeb498181167c615303/requests-2.27.0.tar.gz"
146
+ # }
147
+ # ]
148
+ # }
149
+ # }
150
+ sig do
151
+ params(index_url: String)
152
+ .returns(T.nilable(T::Array[Dependabot::Package::PackageRelease]))
153
+ end
154
+ def fetch_from_json_registry(index_url)
155
+ json_url = index_url.sub(%r{/simple/?$}i, "/pypi/")
156
+
157
+ Dependabot.logger.info(
158
+ "Fetching release information from json registry at #{sanitized_url(json_url)} for #{dependency.name}"
159
+ )
160
+
161
+ response = registry_json_response_for_dependency(json_url)
162
+
163
+ return nil unless response.status == 200
164
+
165
+ begin
166
+ data = JSON.parse(response.body)
167
+
168
+ version_releases = data["releases"]
169
+
170
+ releases = format_version_releases(version_releases)
171
+
172
+ releases.sort_by(&:version).reverse
173
+ rescue JSON::ParserError
174
+ Dependabot.logger.warn("JSON parsing error for #{json_url}. Falling back to HTML.")
175
+ nil
176
+ rescue StandardError => e
177
+ Dependabot.logger.warn("Unexpected error while fetching JSON data: #{e.message}.")
178
+ nil
179
+ end
180
+ end
181
+
182
+ # This URL points to the Simple Index API for the "requests" package on PyPI.
183
+ # It provides an HTML listing of available package versions following PEP 503 (Simple Repository API).
184
+ # The information found here is useful for dependency resolution and package version retrieval.
185
+ #
186
+ # ✅ Information available in the Simple Index:
187
+ # - A list of package versions as anchor (`<a>`) elements.
188
+ # - URLs to distribution files (e.g., `.tar.gz`, `.whl`).
189
+ # - The `data-requires-python` attribute (if present) specifying the required Python version.
190
+ # - An optional `data-yanked` attribute indicating a yanked (withdrawn) version.
191
+ #
192
+ # ❌ Information NOT available in the Simple Index:
193
+ # - Release timestamps (upload time).
194
+ # - File digests (hashes like SHA256, MD5).
195
+ # - Package metadata such as description, author, or dependencies.
196
+ # - Download statistics.
197
+ # - Package type (`sdist` or `bdist_wheel`).
198
+ #
199
+ # To obtain full package metadata, use the PyPI JSON API:
200
+ # - JSON API: https://pypi.org/pypi/requests/json
201
+ #
202
+ # More details: https://www.python.org/dev/peps/pep-0503/
203
+ sig { params(index_url: String).returns(T::Array[Dependabot::Package::PackageRelease]) }
204
+ def fetch_from_html_registry(index_url)
205
+ Dependabot.logger.info(
206
+ "Fetching release information from html registry at #{sanitized_url(index_url)} for #{dependency.name}"
207
+ )
208
+ index_response = registry_response_for_dependency(index_url)
209
+ if index_response.status == 401 || index_response.status == 403
210
+ registry_index_response = registry_index_response(index_url)
211
+
212
+ if registry_index_response.status == 401 || registry_index_response.status == 403
213
+ raise PrivateSourceAuthenticationFailure, sanitized_url(index_url)
214
+ end
215
+ end
216
+
217
+ version_releases = extract_release_details_json_from_html(index_response.body)
218
+ releases = format_version_releases(version_releases)
219
+
220
+ releases.sort_by(&:version).reverse
221
+ end
222
+
223
+ sig do
224
+ params(html_body: String)
225
+ .returns(T::Hash[String, T::Array[T::Hash[String, T.untyped]]]) # Returns JSON-like format
226
+ end
227
+ def extract_release_details_json_from_html(html_body)
228
+ doc = Nokogiri::HTML(html_body)
229
+
230
+ releases = {}
231
+
232
+ doc.css("a").each do |a_tag|
233
+ details = version_details_from_link(a_tag.to_s)
234
+ if details && details["version"]
235
+ releases[details["version"]] ||= []
236
+ releases[details["version"]] << details
237
+ end
238
+ end
239
+
240
+ releases
241
+ end
242
+
243
+ # rubocop:disable Metrics/PerceivedComplexity
244
+ sig do
245
+ params(link: T.nilable(String))
246
+ .returns(T.nilable(T::Hash[String, T.untyped]))
247
+ end
248
+ def version_details_from_link(link)
249
+ return unless link
250
+
251
+ doc = Nokogiri::XML(link)
252
+ filename = doc.at_css("a")&.content
253
+ url = doc.at_css("a")&.attributes&.fetch("href", nil)&.value
254
+
255
+ return unless filename&.match?(name_regex) || url&.match?(name_regex)
256
+
257
+ version = get_version_from_filename(filename)
258
+ return unless version_class.correct?(version)
259
+
260
+ {
261
+ "version" => version,
262
+ "requires_python" => requires_python_from_link(link),
263
+ "yanked" => link.include?("data-yanked"),
264
+ "url" => link
265
+ }
266
+ end
267
+ # rubocop:enable Metrics/PerceivedComplexity
268
+
269
+ sig do
270
+ params(
271
+ releases_json: T.nilable(T::Hash[String, T::Array[T::Hash[String, T.untyped]]])
272
+ )
273
+ .returns(T::Array[Dependabot::Package::PackageRelease])
274
+ end
275
+ def format_version_releases(releases_json)
276
+ return [] unless releases_json
277
+
278
+ releases_json.each_with_object([]) do |(version, release_data_array), versions|
279
+ release_data = release_data_array.last
280
+
281
+ next unless release_data
282
+
283
+ release = format_version_release(version, release_data)
284
+
285
+ next unless release
286
+
287
+ versions << release
288
+ end
289
+ end
290
+
291
+ sig do
292
+ params(
293
+ version: String,
294
+ release_data: T::Hash[String, T.untyped]
295
+ )
296
+ .returns(T.nilable(Dependabot::Package::PackageRelease))
297
+ end
298
+ def format_version_release(version, release_data)
299
+ upload_time = release_data["upload_time"]
300
+ released_at = Time.parse(upload_time) if upload_time
301
+ yanked = release_data["yanked"] || false
302
+ yanked_reason = release_data["yanked_reason"]
303
+ downloads = release_data["downloads"] || -1
304
+ url = release_data["url"]
305
+ package_type = release_data["packagetype"]
306
+ language = package_language(
307
+ python_version: release_data["python_version"],
308
+ requires_python: release_data["requires_python"]
309
+ )
310
+
311
+ release = Dependabot::Package::PackageRelease.new(
312
+ version: Dependabot::Uv::Version.new(version),
313
+ released_at: released_at,
314
+ yanked: yanked,
315
+ yanked_reason: yanked_reason,
316
+ downloads: downloads,
317
+ url: url,
318
+ package_type: package_type,
319
+ language: language
320
+ )
321
+ release
322
+ end
323
+
324
+ sig do
325
+ params(
326
+ python_version: T.nilable(String),
327
+ requires_python: T.nilable(String)
328
+ )
329
+ .returns(T.nilable(Dependabot::Package::PackageLanguage))
330
+ end
331
+ def package_language(python_version:, requires_python:)
332
+ # Extract language name and version
333
+ language_name, language_version = convert_language_version(python_version)
334
+
335
+ # Extract language requirement
336
+ language_requirement = build_python_requirement(requires_python)
337
+
338
+ return nil unless language_version || language_requirement
339
+
340
+ # Return a Language object with all details
341
+ Dependabot::Package::PackageLanguage.new(
342
+ name: language_name,
343
+ version: language_version,
344
+ requirement: language_requirement
345
+ )
346
+ end
347
+
348
+ sig { params(version: T.nilable(String)).returns([String, T.nilable(Dependabot::Version)]) }
349
+ def convert_language_version(version)
350
+ return ["python", nil] if version.nil? || version == "source"
351
+
352
+ # Extract numeric parts dynamically (e.g., "cp37" -> "3.7", "py38" -> "3.8")
353
+ extracted_version = version.scan(/\d+/).join(".")
354
+
355
+ # Detect the language implementation
356
+ language_name = if version.start_with?("cp")
357
+ "cpython" # CPython implementation
358
+ elsif version.start_with?("py")
359
+ "python" # General Python compatibility
360
+ else
361
+ "unknown" # Fallback for unknown cases
362
+ end
363
+
364
+ # Ensure extracted version is valid before converting
365
+ language_version =
366
+ extracted_version.match?(/^\d+(\.\d+)*$/) ? Dependabot::Version.new(extracted_version) : nil
367
+
368
+ Dependabot.logger.warn("Skipping invalid language_version: #{version.inspect}") if language_version.nil?
369
+
370
+ [language_name, language_version]
371
+ end
372
+
373
+ sig { returns(T::Array[String]) }
374
+ def registry_urls
375
+ @registry_urls ||=
376
+ Package::PackageRegistryFinder.new(
377
+ dependency_files: dependency_files,
378
+ credentials: credentials,
379
+ dependency: dependency
380
+ ).registry_urls
381
+ end
382
+
383
+ sig { returns(String) }
384
+ def normalised_name
385
+ NameNormaliser.normalise(dependency.name)
386
+ end
387
+
388
+ sig { params(json_url: String).returns(Excon::Response) }
389
+ def registry_json_response_for_dependency(json_url)
390
+ url = "#{json_url.chomp('/')}/#{@dependency.name}/json"
391
+ Dependabot::RegistryClient.get(
392
+ url: url,
393
+ headers: { "Accept" => APPLICATION_JSON }
394
+ )
395
+ end
396
+
397
+ sig { params(index_url: String).returns(Excon::Response) }
398
+ def registry_response_for_dependency(index_url)
399
+ Dependabot::RegistryClient.get(
400
+ url: index_url + normalised_name + "/",
401
+ headers: { "Accept" => APPLICATION_TEXT }
402
+ )
403
+ end
404
+
405
+ sig { params(index_url: String).returns(Excon::Response) }
406
+ def registry_index_response(index_url)
407
+ Dependabot::RegistryClient.get(
408
+ url: index_url,
409
+ headers: { "Accept" => APPLICATION_TEXT }
410
+ )
411
+ end
412
+
413
+ sig { params(filename: String).returns(T.nilable(String)) }
414
+ def get_version_from_filename(filename)
415
+ filename
416
+ .gsub(/#{name_regex}-/i, "")
417
+ .split(/-|\.tar\.|\.zip|\.whl/)
418
+ .first
419
+ end
420
+
421
+ sig do
422
+ params(req_string: T.nilable(String))
423
+ .returns(T.nilable(Dependabot::Requirement))
424
+ end
425
+ def build_python_requirement(req_string)
426
+ return nil unless req_string
427
+
428
+ requirement_class.new(CGI.unescapeHTML(req_string))
429
+ rescue Gem::Requirement::BadRequirementError
430
+ nil
431
+ end
432
+
433
+ sig { params(link: String).returns(T.nilable(String)) }
434
+ def requires_python_from_link(link)
435
+ raw_value = Nokogiri::XML(link)
436
+ .at_css("a")
437
+ &.attribute("data-requires-python")
438
+ &.content
439
+
440
+ return nil unless raw_value
441
+
442
+ CGI.unescapeHTML(raw_value) # Decodes HTML entities like &gt;=3 → >=3
443
+ end
444
+
445
+ sig { returns(T.class_of(Dependabot::Version)) }
446
+ def version_class
447
+ dependency.version_class
448
+ end
449
+
450
+ sig { returns(T.class_of(Dependabot::Requirement)) }
451
+ def requirement_class
452
+ dependency.requirement_class
453
+ end
454
+
455
+ sig { params(index_url: String).returns(T::Hash[String, String]) }
456
+ def auth_headers_for(index_url)
457
+ credential = @credentials.find { |cred| cred["index-url"] == index_url }
458
+ return {} unless credential
459
+
460
+ { "Authorization" => "Basic #{Base64.strict_encode64(
461
+ "#{credential[CREDENTIALS_USERNAME]}:#{credential[CREDENTIALS_PASSWORD]}"
462
+ )}" }
463
+ end
464
+
465
+ sig { returns(Regexp) }
466
+ def name_regex
467
+ parts = normalised_name.split(/[\s_.-]/).map { |n| Regexp.quote(n) }
468
+ /#{parts.join("[\s_.-]")}/i
469
+ end
470
+
471
+ sig { params(index_url: T.nilable(String)).returns(T::Boolean) }
472
+ def validate_index(index_url)
473
+ return false unless index_url
474
+
475
+ return true if index_url.match?(URI::DEFAULT_PARSER.regexp[:ABS_URI])
476
+
477
+ raise Dependabot::DependencyFileNotResolvable,
478
+ "Invalid URL: #{sanitized_url(index_url)}"
479
+ end
480
+
481
+ sig { params(index_url: String).returns(String) }
482
+ def sanitized_url(index_url)
483
+ index_url.sub(%r{//([^/@]+)@}, "//redacted@")
484
+ end
485
+ end
486
+ end
487
+ end
488
+ end
@@ -7,8 +7,9 @@ require "dependabot/errors"
7
7
 
8
8
  module Dependabot
9
9
  module Uv
10
- class UpdateChecker
11
- class IndexFinder
10
+ module Package
11
+ class PackageRegistryFinder
12
+ extend T::Sig
12
13
  PYPI_BASE_URL = "https://pypi.org/simple/"
13
14
  ENVIRONMENT_VARIABLE_REGEX = /\$\{.+\}/
14
15
 
@@ -18,7 +19,8 @@ module Dependabot
18
19
  @dependency = dependency
19
20
  end
20
21
 
21
- def index_urls
22
+ sig { returns(T::Array[String]) }
23
+ def registry_urls
22
24
  extra_index_urls =
23
25
  config_variable_index_urls[:extra] +
24
26
  pipfile_index_urls[:extra] +
@@ -140,7 +142,7 @@ module Dependabot
140
142
  end
141
143
 
142
144
  def config_variable_index_urls
143
- urls = { main: nil, extra: [] }
145
+ urls = { main: T.let(nil, T.nilable(String)), extra: [] }
144
146
 
145
147
  index_url_creds = credentials
146
148
  .select { |cred| cred["type"] == "python_index" }
@@ -158,7 +160,7 @@ module Dependabot
158
160
  end
159
161
 
160
162
  def clean_check_and_remove_environment_variables(url)
161
- url = url.strip.gsub(%r{/*$}, "") + "/"
163
+ url = url.strip.sub(%r{/+$}, "") + "/"
162
164
 
163
165
  return authed_base_url(url) unless url.match?(ENVIRONMENT_VARIABLE_REGEX)
164
166
 
@@ -190,7 +192,11 @@ module Dependabot
190
192
  cred = credential_for(base_url)
191
193
  return base_url unless cred
192
194
 
193
- AuthedUrlBuilder.authed_url(credential: cred).gsub(%r{/*$}, "") + "/"
195
+ builder = AuthedUrlBuilder.authed_url(credential: cred)
196
+
197
+ return base_url unless builder
198
+
199
+ builder.gsub(%r{/*$}, "") + "/"
194
200
  end
195
201
 
196
202
  def credential_for(url)