dependabot-uv 0.301.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8eea1accc31d49dda91328296fc952b9d49a1e3cfddb443772b1cd794905779
4
- data.tar.gz: a6978d2a2679d009129333b785331ee903b19b5ef673bdba89d81a1b8d67473f
3
+ metadata.gz: 26af709186ad20b222961b28c19395f53edfcab51c9fdc0e92734cd06aa2d296
4
+ data.tar.gz: c26d70a21a979a655c952c940c0f32a10f89e58cb1d1588238b64c252bf96c48
5
5
  SHA512:
6
- metadata.gz: 5e5c87148fd6d1db52d0a5fe59b6855a2e0fa50396b6613cb01f7d9ef86845b3c191b5b3dd8188f245c1fc230617c44b8f0531bd03fb4f15b695fc60d2674ee5
7
- data.tar.gz: 2802f6b46388ca8a30bebfc56cf02594eaff5cf87aed03cf0c79facf0e6f13b62b150426ea2341f636607d2e8f48e899a92724451ffca00d2397c3870ca2c01e
6
+ metadata.gz: d0c2ac20cafd314d293a7a903947cd7fef9df95c5a85fae9f46e41d78920ae856949357cb4e6419c1804e95c19ba625c2a8fbd8bef43d9154d912c6aa31adb43
7
+ data.tar.gz: e1cb4fe17761e4f1433f9a55ae619a9271ec49359a3de0198c807f9393748633d9bfddb85002c01d782eadfc40f0a6598488fde5d9a7044e1c4e844198a26dd5
@@ -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)
@@ -1,4 +1,4 @@
1
- # typed: true
1
+ # typed: strong
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "cgi"
@@ -12,284 +12,30 @@ require "dependabot/update_checkers/version_filters"
12
12
  require "dependabot/registry_client"
13
13
  require "dependabot/uv/authed_url_builder"
14
14
  require "dependabot/uv/name_normaliser"
15
+ require "dependabot/uv/package/package_registry_finder"
16
+ require "dependabot/uv/package/package_details_fetcher"
17
+ require "dependabot/package/package_latest_version_finder"
15
18
 
16
19
  module Dependabot
17
20
  module Uv
18
21
  class UpdateChecker
19
- class LatestVersionFinder
22
+ class LatestVersionFinder < Dependabot::Package::PackageLatestVersionFinder
20
23
  extend T::Sig
21
24
 
22
- require_relative "index_finder"
23
-
24
- def initialize(dependency:, dependency_files:, credentials:,
25
- ignored_versions:, raise_on_ignored: false,
26
- security_advisories:)
27
- @dependency = dependency
28
- @dependency_files = dependency_files
29
- @credentials = credentials
30
- @ignored_versions = ignored_versions
31
- @raise_on_ignored = raise_on_ignored
32
- @security_advisories = security_advisories
33
- end
34
-
35
- def latest_version(python_version: nil)
36
- @latest_version ||=
37
- fetch_latest_version(python_version: python_version)
38
- end
39
-
40
- def latest_version_with_no_unlock(python_version: nil)
41
- @latest_version_with_no_unlock ||=
42
- fetch_latest_version_with_no_unlock(python_version: python_version)
43
- end
44
-
45
- def lowest_security_fix_version(python_version: nil)
46
- @lowest_security_fix_version ||=
47
- fetch_lowest_security_fix_version(python_version: python_version)
48
- end
49
-
50
- private
51
-
52
- attr_reader :dependency
53
- attr_reader :dependency_files
54
- attr_reader :credentials
55
- attr_reader :ignored_versions
56
- attr_reader :security_advisories
57
-
58
- def fetch_latest_version(python_version:)
59
- versions = available_versions
60
- versions = filter_yanked_versions(versions)
61
- versions = filter_unsupported_versions(versions, python_version)
62
- versions = filter_prerelease_versions(versions)
63
- versions = filter_ignored_versions(versions)
64
- versions.max
65
- end
66
-
67
- def fetch_latest_version_with_no_unlock(python_version:)
68
- versions = available_versions
69
- versions = filter_yanked_versions(versions)
70
- versions = filter_unsupported_versions(versions, python_version)
71
- versions = filter_prerelease_versions(versions)
72
- versions = filter_ignored_versions(versions)
73
- versions = filter_out_of_range_versions(versions)
74
- versions.max
75
- end
76
-
77
- def fetch_lowest_security_fix_version(python_version:)
78
- versions = available_versions
79
- versions = filter_yanked_versions(versions)
80
- versions = filter_unsupported_versions(versions, python_version)
81
- versions = filter_prerelease_versions(versions)
82
- versions = Dependabot::UpdateCheckers::VersionFilters.filter_vulnerable_versions(versions,
83
- security_advisories)
84
- versions = filter_ignored_versions(versions)
85
- versions = filter_lower_versions(versions)
86
-
87
- versions.min
88
- end
89
-
90
- sig { params(versions_array: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
91
- def filter_yanked_versions(versions_array)
92
- filtered = versions_array.reject { |details| details.fetch(:yanked) }
93
- if versions_array.count > filtered.count
94
- Dependabot.logger.info("Filtered out #{versions_array.count - filtered.count} yanked versions")
95
- end
96
- filtered
97
- end
98
-
99
25
  sig do
100
- params(versions_array: T::Array[T.untyped], python_version: T.nilable(T.any(String, Version)))
101
- .returns(T::Array[T.untyped])
102
- end
103
- def filter_unsupported_versions(versions_array, python_version)
104
- filtered = versions_array.filter_map do |details|
105
- python_requirement = details.fetch(:python_requirement)
106
- next details.fetch(:version) unless python_version
107
- next details.fetch(:version) unless python_requirement
108
- next unless python_requirement.satisfied_by?(python_version)
109
-
110
- details.fetch(:version)
111
- end
112
- if versions_array.count > filtered.count
113
- delta = versions_array.count - filtered.count
114
- Dependabot.logger.info("Filtered out #{delta} unsupported Python #{python_version} versions")
115
- end
116
- filtered
117
- end
118
-
119
- sig { params(versions_array: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
120
- def filter_prerelease_versions(versions_array)
121
- return versions_array if wants_prerelease?
122
-
123
- filtered = versions_array.reject(&:prerelease?)
124
-
125
- if versions_array.count > filtered.count
126
- Dependabot.logger.info("Filtered out #{versions_array.count - filtered.count} pre-release versions")
127
- end
128
-
129
- filtered
130
- end
131
-
132
- sig { params(versions_array: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
133
- def filter_ignored_versions(versions_array)
134
- filtered = versions_array
135
- .reject { |v| ignore_requirements.any? { |r| r.satisfied_by?(v) } }
136
- if @raise_on_ignored && filter_lower_versions(filtered).empty? && filter_lower_versions(versions_array).any?
137
- raise Dependabot::AllVersionsIgnored
138
- end
139
-
140
- if versions_array.count > filtered.count
141
- Dependabot.logger.info("Filtered out #{versions_array.count - filtered.count} ignored versions")
142
- end
143
- filtered
144
- end
145
-
146
- def filter_lower_versions(versions_array)
147
- return versions_array unless dependency.numeric_version
148
-
149
- versions_array.select { |version| version > dependency.numeric_version }
150
- end
151
-
152
- def filter_out_of_range_versions(versions_array)
153
- reqs = dependency.requirements.filter_map do |r|
154
- requirement_class.requirements_array(r.fetch(:requirement))
155
- end
156
-
157
- versions_array
158
- .select { |v| reqs.all? { |r| r.any? { |o| o.satisfied_by?(v) } } }
159
- end
160
-
161
- def wants_prerelease?
162
- return version_class.new(dependency.version).prerelease? if dependency.version
163
-
164
- dependency.requirements.any? do |req|
165
- reqs = (req.fetch(:requirement) || "").split(",").map(&:strip)
166
- reqs.any? { |r| r.match?(/[A-Za-z]/) }
167
- end
168
- end
169
-
170
- # See https://www.python.org/dev/peps/pep-0503/ for details of the
171
- # Simple Repository API we use here.
172
- def available_versions
173
- @available_versions ||=
174
- index_urls.flat_map do |index_url|
175
- validate_index(index_url)
176
-
177
- sanitized_url = index_url.gsub(%r{(?<=//).*(?=@)}, "redacted")
178
-
179
- index_response = registry_response_for_dependency(index_url)
180
- if index_response.status == 401 || index_response.status == 403
181
- registry_index_response = registry_index_response(index_url)
182
-
183
- if registry_index_response.status == 401 || registry_index_response.status == 403
184
- raise PrivateSourceAuthenticationFailure, sanitized_url
185
- end
186
- end
187
-
188
- version_links = []
189
- index_response.body.scan(%r{<a\s.*?>.*?</a>}m) do
190
- details = version_details_from_link(Regexp.last_match.to_s)
191
- version_links << details if details
192
- end
193
-
194
- version_links.compact
195
- rescue Excon::Error::Timeout, Excon::Error::Socket
196
- raise if MAIN_PYPI_INDEXES.include?(index_url)
197
-
198
- raise PrivateSourceTimedOut, sanitized_url
199
- rescue URI::InvalidURIError
200
- raise DependencyFileNotResolvable, "Invalid URL: #{sanitized_url}"
201
- end
26
+ override.returns(T.nilable(Dependabot::Package::PackageDetails))
202
27
  end
203
-
204
- # rubocop:disable Metrics/PerceivedComplexity
205
- def version_details_from_link(link)
206
- doc = Nokogiri::XML(link)
207
- filename = doc.at_css("a")&.content
208
- url = doc.at_css("a")&.attributes&.fetch("href", nil)&.value
209
- return unless filename&.match?(name_regex) || url&.match?(name_regex)
210
-
211
- version = get_version_from_filename(filename)
212
- return unless version_class.correct?(version)
213
-
214
- {
215
- version: version_class.new(version),
216
- python_requirement: build_python_requirement_from_link(link),
217
- yanked: link&.include?("data-yanked")
218
- }
219
- end
220
- # rubocop:enable Metrics/PerceivedComplexity
221
-
222
- def get_version_from_filename(filename)
223
- filename
224
- .gsub(/#{name_regex}-/i, "")
225
- .split(/-|\.tar\.|\.zip|\.whl/)
226
- .first
227
- end
228
-
229
- def build_python_requirement_from_link(link)
230
- req_string = Nokogiri::XML(link)
231
- .at_css("a")
232
- &.attribute("data-requires-python")
233
- &.content
234
-
235
- return unless req_string
236
-
237
- requirement_class.new(CGI.unescapeHTML(req_string))
238
- rescue Gem::Requirement::BadRequirementError
239
- nil
28
+ def package_details
29
+ @package_details ||= Package::PackageDetailsFetcher.new(
30
+ dependency: dependency,
31
+ dependency_files: dependency_files,
32
+ credentials: credentials
33
+ ).fetch
240
34
  end
241
35
 
242
- def index_urls
243
- @index_urls ||=
244
- IndexFinder.new(
245
- dependency_files: dependency_files,
246
- credentials: credentials,
247
- dependency: dependency
248
- ).index_urls
249
- end
250
-
251
- def registry_response_for_dependency(index_url)
252
- Dependabot::RegistryClient.get(
253
- url: index_url + normalised_name + "/",
254
- headers: { "Accept" => "text/html" }
255
- )
256
- end
257
-
258
- def registry_index_response(index_url)
259
- Dependabot::RegistryClient.get(
260
- url: index_url,
261
- headers: { "Accept" => "text/html" }
262
- )
263
- end
264
-
265
- def ignore_requirements
266
- ignored_versions.flat_map { |req| requirement_class.requirements_array(req) }
267
- end
268
-
269
- def normalised_name
270
- NameNormaliser.normalise(dependency.name)
271
- end
272
-
273
- def name_regex
274
- parts = normalised_name.split(/[\s_.-]/).map { |n| Regexp.quote(n) }
275
- /#{parts.join("[\s_.-]")}/i
276
- end
277
-
278
- def version_class
279
- dependency.version_class
280
- end
281
-
282
- def requirement_class
283
- dependency.requirement_class
284
- end
285
-
286
- def validate_index(index_url)
287
- sanitized_url = index_url.gsub(%r{(?<=//).*(?=@)}, "redacted")
288
-
289
- return if index_url&.match?(URI::DEFAULT_PARSER.regexp[:ABS_URI])
290
-
291
- raise Dependabot::DependencyFileNotResolvable,
292
- "Invalid URL: #{sanitized_url}"
36
+ sig { override.returns(T::Boolean) }
37
+ def cooldown_enabled?
38
+ Dependabot::Experiments.enabled?(:enable_cooldown_for_uv)
293
39
  end
294
40
  end
295
41
  end
@@ -11,28 +11,29 @@ module Dependabot
11
11
  class UpdateChecker
12
12
  class PipVersionResolver
13
13
  def initialize(dependency:, dependency_files:, credentials:,
14
- ignored_versions:, raise_on_ignored: false,
14
+ ignored_versions:, update_cooldown: nil, raise_on_ignored: false,
15
15
  security_advisories:)
16
- @dependency = dependency
16
+ @dependency = dependency
17
17
  @dependency_files = dependency_files
18
18
  @credentials = credentials
19
19
  @ignored_versions = ignored_versions
20
+ @update_cooldown = update_cooldown
20
21
  @raise_on_ignored = raise_on_ignored
21
22
  @security_advisories = security_advisories
22
23
  end
23
24
 
24
25
  def latest_resolvable_version
25
- latest_version_finder.latest_version(python_version: language_version_manager.python_version)
26
+ latest_version_finder.latest_version(language_version: language_version_manager.python_version)
26
27
  end
27
28
 
28
29
  def latest_resolvable_version_with_no_unlock
29
30
  latest_version_finder
30
- .latest_version_with_no_unlock(python_version: language_version_manager.python_version)
31
+ .latest_version_with_no_unlock(language_version: language_version_manager.python_version)
31
32
  end
32
33
 
33
34
  def lowest_resolvable_security_fix_version
34
35
  latest_version_finder
35
- .lowest_security_fix_version(python_version: language_version_manager.python_version)
36
+ .lowest_security_fix_version(language_version: language_version_manager.python_version)
36
37
  end
37
38
 
38
39
  private
@@ -50,6 +51,7 @@ module Dependabot
50
51
  credentials: credentials,
51
52
  ignored_versions: ignored_versions,
52
53
  raise_on_ignored: @raise_on_ignored,
54
+ cooldown_options: @update_cooldown,
53
55
  security_advisories: security_advisories
54
56
  )
55
57
  end
@@ -170,6 +170,7 @@ module Dependabot
170
170
  credentials: credentials,
171
171
  ignored_versions: ignored_versions,
172
172
  raise_on_ignored: @raise_on_ignored,
173
+ update_cooldown: @update_cooldown,
173
174
  security_advisories: security_advisories
174
175
  )
175
176
  end
@@ -241,6 +242,7 @@ module Dependabot
241
242
  credentials: credentials,
242
243
  ignored_versions: ignored_versions,
243
244
  raise_on_ignored: @raise_on_ignored,
245
+ cooldown_options: @update_cooldown,
244
246
  security_advisories: security_advisories
245
247
  )
246
248
  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.301.0
4
+ version: 0.301.1
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-13 00:00:00.000000000 Z
11
+ date: 2025-03-14 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.301.0
19
+ version: 0.301.1
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.301.0
26
+ version: 0.301.1
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: debug
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -266,13 +266,14 @@ files:
266
266
  - lib/dependabot/uv/metadata_finder.rb
267
267
  - lib/dependabot/uv/name_normaliser.rb
268
268
  - lib/dependabot/uv/native_helpers.rb
269
+ - lib/dependabot/uv/package/package_details_fetcher.rb
270
+ - lib/dependabot/uv/package/package_registry_finder.rb
269
271
  - lib/dependabot/uv/package_manager.rb
270
272
  - lib/dependabot/uv/pipenv_runner.rb
271
273
  - lib/dependabot/uv/requirement.rb
272
274
  - lib/dependabot/uv/requirement_parser.rb
273
275
  - lib/dependabot/uv/requirements_file_matcher.rb
274
276
  - lib/dependabot/uv/update_checker.rb
275
- - lib/dependabot/uv/update_checker/index_finder.rb
276
277
  - lib/dependabot/uv/update_checker/latest_version_finder.rb
277
278
  - lib/dependabot/uv/update_checker/lock_file_resolver.rb
278
279
  - lib/dependabot/uv/update_checker/pip_compile_version_resolver.rb
@@ -284,7 +285,7 @@ licenses:
284
285
  - MIT
285
286
  metadata:
286
287
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
287
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.301.0
288
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.301.1
288
289
  post_install_message:
289
290
  rdoc_options: []
290
291
  require_paths: