dependabot-uv 0.299.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/helpers/build +34 -0
  3. data/helpers/lib/__init__.py +0 -0
  4. data/helpers/lib/hasher.py +36 -0
  5. data/helpers/lib/parser.py +270 -0
  6. data/helpers/requirements.txt +13 -0
  7. data/helpers/run.py +22 -0
  8. data/lib/dependabot/uv/authed_url_builder.rb +31 -0
  9. data/lib/dependabot/uv/file_fetcher.rb +328 -0
  10. data/lib/dependabot/uv/file_parser/pipfile_files_parser.rb +192 -0
  11. data/lib/dependabot/uv/file_parser/pyproject_files_parser.rb +345 -0
  12. data/lib/dependabot/uv/file_parser/python_requirement_parser.rb +185 -0
  13. data/lib/dependabot/uv/file_parser/setup_file_parser.rb +193 -0
  14. data/lib/dependabot/uv/file_parser.rb +437 -0
  15. data/lib/dependabot/uv/file_updater/compile_file_updater.rb +576 -0
  16. data/lib/dependabot/uv/file_updater/pyproject_preparer.rb +124 -0
  17. data/lib/dependabot/uv/file_updater/requirement_file_updater.rb +73 -0
  18. data/lib/dependabot/uv/file_updater/requirement_replacer.rb +214 -0
  19. data/lib/dependabot/uv/file_updater.rb +105 -0
  20. data/lib/dependabot/uv/language.rb +76 -0
  21. data/lib/dependabot/uv/language_version_manager.rb +114 -0
  22. data/lib/dependabot/uv/metadata_finder.rb +186 -0
  23. data/lib/dependabot/uv/name_normaliser.rb +26 -0
  24. data/lib/dependabot/uv/native_helpers.rb +38 -0
  25. data/lib/dependabot/uv/package_manager.rb +54 -0
  26. data/lib/dependabot/uv/pip_compile_file_matcher.rb +38 -0
  27. data/lib/dependabot/uv/pipenv_runner.rb +108 -0
  28. data/lib/dependabot/uv/requirement.rb +163 -0
  29. data/lib/dependabot/uv/requirement_parser.rb +60 -0
  30. data/lib/dependabot/uv/update_checker/index_finder.rb +227 -0
  31. data/lib/dependabot/uv/update_checker/latest_version_finder.rb +297 -0
  32. data/lib/dependabot/uv/update_checker/pip_compile_version_resolver.rb +506 -0
  33. data/lib/dependabot/uv/update_checker/pip_version_resolver.rb +73 -0
  34. data/lib/dependabot/uv/update_checker/requirements_updater.rb +391 -0
  35. data/lib/dependabot/uv/update_checker.rb +317 -0
  36. data/lib/dependabot/uv/version.rb +321 -0
  37. data/lib/dependabot/uv.rb +35 -0
  38. metadata +306 -0
@@ -0,0 +1,227 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "dependabot/uv/update_checker"
5
+ require "dependabot/uv/authed_url_builder"
6
+ require "dependabot/errors"
7
+
8
+ module Dependabot
9
+ module Uv
10
+ class UpdateChecker
11
+ class IndexFinder
12
+ PYPI_BASE_URL = "https://pypi.org/simple/"
13
+ ENVIRONMENT_VARIABLE_REGEX = /\$\{.+\}/
14
+
15
+ def initialize(dependency_files:, credentials:, dependency:)
16
+ @dependency_files = dependency_files
17
+ @credentials = credentials
18
+ @dependency = dependency
19
+ end
20
+
21
+ def index_urls
22
+ extra_index_urls =
23
+ config_variable_index_urls[:extra] +
24
+ pipfile_index_urls[:extra] +
25
+ requirement_file_index_urls[:extra] +
26
+ pip_conf_index_urls[:extra] +
27
+ pyproject_index_urls[:extra]
28
+
29
+ extra_index_urls = extra_index_urls.map do |url|
30
+ clean_check_and_remove_environment_variables(url)
31
+ end
32
+
33
+ # URL encode any `@` characters within registry URL creds.
34
+ # TODO: The test that fails if the `map` here is removed is likely a
35
+ # bug in Ruby's URI parser, and should be fixed there.
36
+ [main_index_url, *extra_index_urls].map do |url|
37
+ url.rpartition("@").tap { |a| a.first.gsub!("@", "%40") }.join
38
+ end.uniq
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :dependency_files
44
+ attr_reader :credentials
45
+
46
+ def main_index_url
47
+ url =
48
+ config_variable_index_urls[:main] ||
49
+ pipfile_index_urls[:main] ||
50
+ requirement_file_index_urls[:main] ||
51
+ pip_conf_index_urls[:main] ||
52
+ pyproject_index_urls[:main] ||
53
+ PYPI_BASE_URL
54
+
55
+ clean_check_and_remove_environment_variables(url)
56
+ end
57
+
58
+ def requirement_file_index_urls
59
+ urls = { main: nil, extra: [] }
60
+
61
+ requirements_files.each do |file|
62
+ if file.content.match?(/^--index-url\s+['"]?([^\s'"]+)['"]?/)
63
+ urls[:main] =
64
+ file.content.match(/^--index-url\s+['"]?([^\s'"]+)['"]?/)
65
+ .captures.first&.strip
66
+ end
67
+ urls[:extra] +=
68
+ file.content
69
+ .scan(/^--extra-index-url\s+['"]?([^\s'"]+)['"]?/)
70
+ .flatten
71
+ .map(&:strip)
72
+ end
73
+
74
+ urls
75
+ end
76
+
77
+ def pip_conf_index_urls
78
+ urls = { main: nil, extra: [] }
79
+
80
+ return urls unless pip_conf
81
+
82
+ content = pip_conf.content
83
+
84
+ if content.match?(/^index-url\s*=/x)
85
+ urls[:main] = content.match(/^index-url\s*=\s*(.+)/)
86
+ .captures.first
87
+ end
88
+ urls[:extra] += content.scan(/^extra-index-url\s*=(.+)/).flatten
89
+
90
+ urls
91
+ end
92
+
93
+ def pipfile_index_urls
94
+ urls = { main: nil, extra: [] }
95
+
96
+ return urls unless pipfile
97
+
98
+ pipfile_object = TomlRB.parse(pipfile.content)
99
+
100
+ urls[:main] = pipfile_object["source"]&.first&.fetch("url", nil)
101
+
102
+ pipfile_object["source"]&.each do |source|
103
+ urls[:extra] << source.fetch("url") if source["url"]
104
+ end
105
+ urls[:extra] = urls[:extra].uniq
106
+
107
+ urls
108
+ rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
109
+ urls
110
+ end
111
+
112
+ def pyproject_index_urls
113
+ urls = { main: nil, extra: [] }
114
+
115
+ return urls unless pyproject
116
+
117
+ sources =
118
+ TomlRB.parse(pyproject.content).dig("tool", "poetry", "source") ||
119
+ []
120
+
121
+ sources.each do |source|
122
+ # If source is PyPI, skip it, and let it pick the default URI
123
+ next if source["name"].casecmp?("PyPI")
124
+
125
+ if @dependency.all_sources.include?(source["name"])
126
+ # If dependency has specified this source, use it
127
+ return { main: source["url"], extra: [] }
128
+ elsif source["default"]
129
+ urls[:main] = source["url"]
130
+ elsif source["priority"] != "explicit"
131
+ # if source is not explicit, add it to extra
132
+ urls[:extra] << source["url"]
133
+ end
134
+ end
135
+ urls[:extra] = urls[:extra].uniq
136
+
137
+ urls
138
+ rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
139
+ urls
140
+ end
141
+
142
+ def config_variable_index_urls
143
+ urls = { main: nil, extra: [] }
144
+
145
+ index_url_creds = credentials
146
+ .select { |cred| cred["type"] == "python_index" }
147
+
148
+ if (main_cred = index_url_creds.find(&:replaces_base?))
149
+ urls[:main] = AuthedUrlBuilder.authed_url(credential: main_cred)
150
+ end
151
+
152
+ urls[:extra] =
153
+ index_url_creds
154
+ .reject(&:replaces_base?)
155
+ .map { |cred| AuthedUrlBuilder.authed_url(credential: cred) }
156
+
157
+ urls
158
+ end
159
+
160
+ def clean_check_and_remove_environment_variables(url)
161
+ url = url.strip.gsub(%r{/*$}, "") + "/"
162
+
163
+ return authed_base_url(url) unless url.match?(ENVIRONMENT_VARIABLE_REGEX)
164
+
165
+ config_variable_urls =
166
+ [
167
+ config_variable_index_urls[:main],
168
+ *config_variable_index_urls[:extra]
169
+ ]
170
+ .compact
171
+ .map { |u| u.strip.gsub(%r{/*$}, "") + "/" }
172
+
173
+ regexp = url
174
+ .sub(%r{(?<=://).+@}, "")
175
+ .sub(%r{https?://}, "")
176
+ .split(ENVIRONMENT_VARIABLE_REGEX)
177
+ .map { |part| Regexp.quote(part) }
178
+ .join(".+")
179
+ authed_url = config_variable_urls.find { |u| u.match?(regexp) }
180
+ return authed_url if authed_url
181
+
182
+ cleaned_url = url.gsub(%r{#{ENVIRONMENT_VARIABLE_REGEX}/?}o, "")
183
+ authed_url = authed_base_url(cleaned_url)
184
+ return authed_url if credential_for(cleaned_url)
185
+
186
+ raise PrivateSourceAuthenticationFailure, url
187
+ end
188
+
189
+ def authed_base_url(base_url)
190
+ cred = credential_for(base_url)
191
+ return base_url unless cred
192
+
193
+ AuthedUrlBuilder.authed_url(credential: cred).gsub(%r{/*$}, "") + "/"
194
+ end
195
+
196
+ def credential_for(url)
197
+ credentials
198
+ .select { |c| c["type"] == "python_index" }
199
+ .find do |c|
200
+ cred_url = c.fetch("index-url").gsub(%r{/*$}, "") + "/"
201
+ cred_url.include?(url)
202
+ end
203
+ end
204
+
205
+ def pip_conf
206
+ dependency_files.find { |f| f.name == "pip.conf" }
207
+ end
208
+
209
+ def pipfile
210
+ dependency_files.find { |f| f.name == "Pipfile" }
211
+ end
212
+
213
+ def pyproject
214
+ dependency_files.find { |f| f.name == "pyproject.toml" }
215
+ end
216
+
217
+ def requirements_files
218
+ dependency_files.select { |f| f.name.match?(/requirements/x) }
219
+ end
220
+
221
+ def pip_compile_files
222
+ dependency_files.select { |f| f.name.end_with?(".in") }
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,297 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "cgi"
5
+ require "excon"
6
+ require "nokogiri"
7
+ require "sorbet-runtime"
8
+
9
+ require "dependabot/dependency"
10
+ require "dependabot/uv/update_checker"
11
+ require "dependabot/update_checkers/version_filters"
12
+ require "dependabot/registry_client"
13
+ require "dependabot/uv/authed_url_builder"
14
+ require "dependabot/uv/name_normaliser"
15
+
16
+ module Dependabot
17
+ module Uv
18
+ class UpdateChecker
19
+ class LatestVersionFinder
20
+ extend T::Sig
21
+
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
+ 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
202
+ 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
240
+ end
241
+
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}"
293
+ end
294
+ end
295
+ end
296
+ end
297
+ end