dependabot-uv 0.349.0 → 0.351.0

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