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,345 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "toml-rb"
5
+
6
+ require "dependabot/dependency"
7
+ require "dependabot/file_parsers/base/dependency_set"
8
+ require "dependabot/uv/file_parser"
9
+ require "dependabot/uv/requirement"
10
+ require "dependabot/errors"
11
+ require "dependabot/uv/name_normaliser"
12
+
13
+ module Dependabot
14
+ module Uv
15
+ class FileParser
16
+ class PyprojectFilesParser
17
+ extend T::Sig
18
+ POETRY_DEPENDENCY_TYPES = %w(dependencies dev-dependencies).freeze
19
+
20
+ # https://python-poetry.org/docs/dependency-specification/
21
+ UNSUPPORTED_DEPENDENCY_TYPES = %w(git path url).freeze
22
+
23
+ sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void }
24
+ def initialize(dependency_files:)
25
+ @dependency_files = dependency_files
26
+ end
27
+
28
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
29
+ def dependency_set
30
+ dependency_set = Dependabot::FileParsers::Base::DependencySet.new
31
+
32
+ dependency_set += pyproject_dependencies if using_poetry? || using_pep621?
33
+ dependency_set += lockfile_dependencies if using_poetry? && lockfile
34
+
35
+ dependency_set
36
+ end
37
+
38
+ private
39
+
40
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
41
+ attr_reader :dependency_files
42
+
43
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
44
+ def pyproject_dependencies
45
+ if using_poetry?
46
+ missing_keys = missing_poetry_keys
47
+
48
+ if missing_keys.any?
49
+ raise DependencyFileNotParseable.new(
50
+ T.must(pyproject).path,
51
+ "#{T.must(pyproject).path} is missing the following sections:\n" \
52
+ " * #{missing_keys.map { |key| "tool.poetry.#{key}" }.join("\n * ")}\n"
53
+ )
54
+ end
55
+
56
+ poetry_dependencies
57
+ else
58
+ pep621_dependencies
59
+ end
60
+ end
61
+
62
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
63
+ def poetry_dependencies
64
+ @poetry_dependencies ||= T.let(parse_poetry_dependencies, T.untyped)
65
+ end
66
+
67
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
68
+ def parse_poetry_dependencies
69
+ dependencies = Dependabot::FileParsers::Base::DependencySet.new
70
+
71
+ POETRY_DEPENDENCY_TYPES.each do |type|
72
+ deps_hash = T.must(poetry_root)[type] || {}
73
+ dependencies += parse_poetry_dependency_group(type, deps_hash)
74
+ end
75
+
76
+ groups = T.must(poetry_root)["group"] || {}
77
+ groups.each do |group, group_spec|
78
+ dependencies += parse_poetry_dependency_group(group, group_spec["dependencies"])
79
+ end
80
+ dependencies
81
+ end
82
+
83
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
84
+ def pep621_dependencies
85
+ dependencies = Dependabot::FileParsers::Base::DependencySet.new
86
+
87
+ # PDM is not yet supported, so we want to ignore it for now because in
88
+ # the current state of things, going on would result in updating
89
+ # pyproject.toml but leaving pdm.lock out of sync, which is
90
+ # undesirable. Leave PDM alone until properly supported
91
+ return dependencies if using_pdm?
92
+
93
+ parsed_pep621_dependencies.each do |dep|
94
+ # If a requirement has a `<` or `<=` marker then updating it is
95
+ # probably blocked. Ignore it.
96
+ next if dep["markers"].include?("<")
97
+
98
+ # If no requirement, don't add it
99
+ next if dep["requirement"].empty?
100
+
101
+ dependencies <<
102
+ Dependency.new(
103
+ name: normalised_name(dep["name"], dep["extras"]),
104
+ version: dep["version"]&.include?("*") ? nil : dep["version"],
105
+ requirements: [{
106
+ requirement: dep["requirement"],
107
+ file: Pathname.new(dep["file"]).cleanpath.to_path,
108
+ source: nil,
109
+ groups: [dep["requirement_type"]].compact
110
+ }],
111
+ package_manager: "uv"
112
+ )
113
+ end
114
+
115
+ dependencies
116
+ end
117
+
118
+ sig do
119
+ params(type: String,
120
+ deps_hash: T::Hash[String,
121
+ T.untyped]).returns(Dependabot::FileParsers::Base::DependencySet)
122
+ end
123
+ def parse_poetry_dependency_group(type, deps_hash)
124
+ dependencies = Dependabot::FileParsers::Base::DependencySet.new
125
+
126
+ deps_hash.each do |name, req|
127
+ next if normalise(name) == "python"
128
+
129
+ requirements = parse_requirements_from(req, type)
130
+ next if requirements.empty?
131
+
132
+ dependencies << Dependency.new(
133
+ name: normalise(name),
134
+ version: version_from_lockfile(name),
135
+ requirements: requirements,
136
+ package_manager: "uv"
137
+ )
138
+ end
139
+ dependencies
140
+ end
141
+
142
+ sig { params(name: String, extras: T::Array[String]).returns(String) }
143
+ def normalised_name(name, extras)
144
+ NameNormaliser.normalise_including_extras(name, extras)
145
+ end
146
+
147
+ # @param req can be an Array, Hash or String that represents the constraints for a dependency
148
+ sig { params(req: T.untyped, type: String).returns(T::Array[T::Hash[Symbol, T.nilable(String)]]) }
149
+ def parse_requirements_from(req, type)
150
+ [req].flatten.compact.filter_map do |requirement|
151
+ next if requirement.is_a?(Hash) && UNSUPPORTED_DEPENDENCY_TYPES.intersect?(requirement.keys)
152
+
153
+ check_requirements(requirement)
154
+
155
+ if requirement.is_a?(String)
156
+ {
157
+ requirement: requirement,
158
+ file: T.must(pyproject).name,
159
+ source: nil,
160
+ groups: [type]
161
+ }
162
+ else
163
+ {
164
+ requirement: requirement["version"],
165
+ file: T.must(pyproject).name,
166
+ source: requirement.fetch("source", nil),
167
+ groups: [type]
168
+ }
169
+ end
170
+ end
171
+ end
172
+
173
+ sig { returns(T.nilable(T::Boolean)) }
174
+ def using_poetry?
175
+ !poetry_root.nil?
176
+ end
177
+
178
+ sig { returns(T::Array[String]) }
179
+ def missing_poetry_keys
180
+ package_mode = T.must(poetry_root).fetch("package-mode", true)
181
+ required_keys = package_mode ? %w(name version description authors) : []
182
+ required_keys.reject { |key| T.must(poetry_root).key?(key) }
183
+ end
184
+
185
+ sig { returns(T::Boolean) }
186
+ def using_pep621?
187
+ !parsed_pyproject.dig("project", "dependencies").nil? ||
188
+ !parsed_pyproject.dig("project", "optional-dependencies").nil? ||
189
+ !parsed_pyproject.dig("build-system", "requires").nil?
190
+ end
191
+
192
+ sig { returns(T.nilable(T::Hash[String, T.untyped])) }
193
+ def poetry_root
194
+ parsed_pyproject.dig("tool", "poetry")
195
+ end
196
+
197
+ sig { returns(T.untyped) }
198
+ def using_pdm?
199
+ using_pep621? && pdm_lock
200
+ end
201
+
202
+ # Create a DependencySet where each element has no requirement. Any
203
+ # requirements will be added when combining the DependencySet with
204
+ # other DependencySets.
205
+ sig { returns(Dependabot::FileParsers::Base::DependencySet) }
206
+ def lockfile_dependencies
207
+ dependencies = Dependabot::FileParsers::Base::DependencySet.new
208
+
209
+ source_types = %w(directory git url)
210
+ parsed_lockfile.fetch("package", []).each do |details|
211
+ next if source_types.include?(details.dig("source", "type"))
212
+
213
+ name = normalise(details.fetch("name"))
214
+
215
+ dependencies <<
216
+ Dependency.new(
217
+ name: name,
218
+ version: details.fetch("version"),
219
+ requirements: [],
220
+ package_manager: "uv",
221
+ subdependency_metadata: [{
222
+ production: production_dependency_names.include?(name)
223
+ }]
224
+ )
225
+ end
226
+
227
+ dependencies
228
+ end
229
+
230
+ sig { returns(T::Array[T.nilable(String)]) }
231
+ def production_dependency_names
232
+ @production_dependency_names ||= T.let(parse_production_dependency_names,
233
+ T.nilable(T::Array[T.nilable(String)]))
234
+ end
235
+
236
+ sig { returns(T::Array[T.nilable(String)]) }
237
+ def parse_production_dependency_names
238
+ SharedHelpers.in_a_temporary_directory do
239
+ File.write(T.must(pyproject).name, T.must(pyproject).content)
240
+ File.write(lockfile.name, lockfile.content)
241
+
242
+ begin
243
+ output = SharedHelpers.run_shell_command("pyenv exec poetry show --only main")
244
+
245
+ output.split("\n").map { |line| line.split.first }
246
+ rescue SharedHelpers::HelperSubprocessFailed
247
+ # Sometimes, we may be dealing with an old lockfile that our
248
+ # poetry version can't show dependency information for. Other
249
+ # commands we use like `poetry update` are more resilient and
250
+ # automatically heal the lockfile. So we rescue the error and make
251
+ # a best effort approach to this.
252
+ poetry_dependencies.dependencies.filter_map do |dep|
253
+ dep.name if dep.production?
254
+ end
255
+ end
256
+ end
257
+ end
258
+
259
+ sig { params(dep_name: String).returns(T.untyped) }
260
+ def version_from_lockfile(dep_name)
261
+ return unless parsed_lockfile
262
+
263
+ parsed_lockfile.fetch("package", [])
264
+ .find { |p| normalise(p.fetch("name")) == normalise(dep_name) }
265
+ &.fetch("version", nil)
266
+ end
267
+
268
+ sig { params(req: T.untyped).returns(T::Array[Dependabot::Uv::Requirement]) }
269
+ def check_requirements(req)
270
+ requirement = req.is_a?(String) ? req : req["version"]
271
+ Uv::Requirement.requirements_array(requirement)
272
+ rescue Gem::Requirement::BadRequirementError => e
273
+ raise Dependabot::DependencyFileNotEvaluatable, e.message
274
+ end
275
+
276
+ sig { params(name: String).returns(String) }
277
+ def normalise(name)
278
+ NameNormaliser.normalise(name)
279
+ end
280
+
281
+ sig { returns(T.untyped) }
282
+ def parsed_pyproject
283
+ @parsed_pyproject ||= T.let(TomlRB.parse(T.must(pyproject).content), T.untyped)
284
+ rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
285
+ raise Dependabot::DependencyFileNotParseable, T.must(pyproject).path
286
+ end
287
+
288
+ sig { returns(T.untyped) }
289
+ def parsed_poetry_lock
290
+ @parsed_poetry_lock ||= T.let(TomlRB.parse(T.must(poetry_lock).content), T.untyped)
291
+ rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
292
+ raise Dependabot::DependencyFileNotParseable, T.must(poetry_lock).path
293
+ end
294
+
295
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
296
+ def pyproject
297
+ @pyproject ||= T.let(dependency_files.find { |f| f.name == "pyproject.toml" },
298
+ T.nilable(Dependabot::DependencyFile))
299
+ end
300
+
301
+ sig { returns(T.untyped) }
302
+ def lockfile
303
+ poetry_lock
304
+ end
305
+
306
+ sig { returns(T.untyped) }
307
+ def parsed_pep621_dependencies
308
+ SharedHelpers.in_a_temporary_directory do
309
+ write_temporary_pyproject
310
+
311
+ SharedHelpers.run_helper_subprocess(
312
+ command: "pyenv exec python3 #{NativeHelpers.python_helper_path}",
313
+ function: "parse_pep621_dependencies",
314
+ args: [T.must(pyproject).name]
315
+ )
316
+ end
317
+ end
318
+
319
+ sig { returns(Integer) }
320
+ def write_temporary_pyproject
321
+ path = T.must(pyproject).name
322
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
323
+ File.write(path, T.must(pyproject).content)
324
+ end
325
+
326
+ sig { returns(T.untyped) }
327
+ def parsed_lockfile
328
+ parsed_poetry_lock if poetry_lock
329
+ end
330
+
331
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
332
+ def poetry_lock
333
+ @poetry_lock ||= T.let(dependency_files.find { |f| f.name == "poetry.lock" },
334
+ T.nilable(Dependabot::DependencyFile))
335
+ end
336
+
337
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
338
+ def pdm_lock
339
+ @pdm_lock ||= T.let(dependency_files.find { |f| f.name == "pdm.lock" },
340
+ T.nilable(Dependabot::DependencyFile))
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,185 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "toml-rb"
5
+ require "open3"
6
+ require "dependabot/errors"
7
+ require "dependabot/shared_helpers"
8
+ require "dependabot/uv/file_parser"
9
+ require "dependabot/uv/pip_compile_file_matcher"
10
+ require "dependabot/uv/requirement"
11
+
12
+ module Dependabot
13
+ module Uv
14
+ class FileParser
15
+ class PythonRequirementParser
16
+ attr_reader :dependency_files
17
+
18
+ def initialize(dependency_files:)
19
+ @dependency_files = dependency_files
20
+ end
21
+
22
+ def user_specified_requirements
23
+ [
24
+ pipfile_python_requirement,
25
+ pyproject_python_requirement,
26
+ pip_compile_python_requirement,
27
+ python_version_file_version,
28
+ runtime_file_python_version,
29
+ setup_file_requirement
30
+ ].compact
31
+ end
32
+
33
+ # TODO: Add better Python version detection using dependency versions
34
+ # (e.g., Django 2.x implies Python 3)
35
+ def imputed_requirements
36
+ requirement_files.flat_map do |file|
37
+ file.content.lines
38
+ .select { |l| l.include?(";") && l.include?("python") }
39
+ .filter_map { |l| l.match(/python_version(?<req>.*?["'].*?['"])/) }
40
+ .map { |re| re.named_captures.fetch("req").gsub(/['"]/, "") }
41
+ .select { |r| valid_requirement?(r) }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def pipfile_python_requirement
48
+ return unless pipfile
49
+
50
+ parsed_pipfile = TomlRB.parse(pipfile.content)
51
+ requirement =
52
+ parsed_pipfile.dig("requires", "python_full_version") ||
53
+ parsed_pipfile.dig("requires", "python_version")
54
+ return unless requirement&.match?(/^\d/)
55
+
56
+ requirement
57
+ end
58
+
59
+ def pyproject_python_requirement
60
+ return unless pyproject
61
+
62
+ pyproject_object = TomlRB.parse(pyproject.content)
63
+
64
+ # Check for PEP621 requires-python
65
+ pep621_python = pyproject_object.dig("project", "requires-python")
66
+ return pep621_python if pep621_python
67
+
68
+ # Fallback to Poetry configuration
69
+ poetry_object = pyproject_object.dig("tool", "poetry")
70
+
71
+ poetry_object&.dig("dependencies", "python") ||
72
+ poetry_object&.dig("dev-dependencies", "python")
73
+ end
74
+
75
+ def pip_compile_python_requirement
76
+ requirement_files.each do |file|
77
+ next unless pip_compile_file_matcher.lockfile_for_pip_compile_file?(file)
78
+
79
+ marker = /^# This file is autogenerated by pip-compile with [pP]ython (?<version>\d+.\d+)$/m
80
+ match = marker.match(file.content)
81
+ next unless match
82
+
83
+ return match[:version]
84
+ end
85
+
86
+ nil
87
+ end
88
+
89
+ def python_version_file_version
90
+ return unless python_version_file
91
+
92
+ # read the content, split into lines and remove any lines with '#'
93
+ content_lines = python_version_file.content.each_line.map do |line|
94
+ line.sub(/#.*$/, " ").strip
95
+ end.reject(&:empty?)
96
+
97
+ file_version = content_lines.first
98
+ return if file_version&.empty?
99
+ return unless pyenv_versions.include?("#{file_version}\n")
100
+
101
+ file_version
102
+ end
103
+
104
+ def runtime_file_python_version
105
+ return unless runtime_file
106
+
107
+ file_version = runtime_file.content
108
+ .match(/(?<=python-).*/)&.to_s&.strip
109
+ return if file_version&.empty?
110
+ return unless pyenv_versions.include?("#{file_version}\n")
111
+
112
+ file_version
113
+ end
114
+
115
+ def setup_file_requirement
116
+ return unless setup_file
117
+
118
+ req = setup_file.content
119
+ .match(/python_requires\s*=\s*['"](?<req>[^'"]+)['"]/)
120
+ &.named_captures&.fetch("req")&.strip
121
+
122
+ requirement_class.new(req)
123
+ req
124
+ rescue Gem::Requirement::BadRequirementError
125
+ nil
126
+ end
127
+
128
+ def pyenv_versions
129
+ @pyenv_versions ||= run_command("pyenv install --list")
130
+ end
131
+
132
+ def run_command(command, env: {})
133
+ SharedHelpers.run_shell_command(command, env: env, stderr_to_stdout: true)
134
+ end
135
+
136
+ def pip_compile_file_matcher
137
+ @pip_compile_file_matcher ||= PipCompileFileMatcher.new(pip_compile_files)
138
+ end
139
+
140
+ def requirement_class
141
+ Dependabot::Uv::Requirement
142
+ end
143
+
144
+ def valid_requirement?(req_string)
145
+ requirement_class.new(req_string)
146
+ true
147
+ rescue Gem::Requirement::BadRequirementError
148
+ false
149
+ end
150
+
151
+ def pipfile
152
+ dependency_files.find { |f| f.name == "Pipfile" }
153
+ end
154
+
155
+ def pipfile_lock
156
+ dependency_files.find { |f| f.name == "Pipfile.lock" }
157
+ end
158
+
159
+ def pyproject
160
+ dependency_files.find { |f| f.name == "pyproject.toml" }
161
+ end
162
+
163
+ def setup_file
164
+ dependency_files.find { |f| f.name == "setup.py" }
165
+ end
166
+
167
+ def python_version_file
168
+ dependency_files.find { |f| f.name == ".python-version" }
169
+ end
170
+
171
+ def runtime_file
172
+ dependency_files.find { |f| f.name.end_with?("runtime.txt") }
173
+ end
174
+
175
+ def requirement_files
176
+ dependency_files.select { |f| f.name.end_with?(".txt") }
177
+ end
178
+
179
+ def pip_compile_files
180
+ dependency_files.select { |f| f.name.end_with?(".in") }
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end