dependabot-python 0.403.0 → 0.404.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.
- checksums.yaml +4 -4
- data/lib/dependabot/python/file_parser/pyproject_document.rb +39 -0
- data/lib/dependabot/python/file_parser/pyproject_files_parser.rb +16 -3
- data/lib/dependabot/python/file_parser.rb +6 -48
- data/lib/dependabot/python/file_updater/poetry_file_updater/pep621_updater.rb +30 -10
- data/lib/dependabot/python/file_updater/poetry_file_updater.rb +20 -0
- data/lib/dependabot/python/file_updater/pyproject_preparer.rb +18 -3
- data/lib/dependabot/python/file_updater.rb +2 -2
- data/lib/dependabot/python/language.rb +5 -5
- data/lib/dependabot/python/marker_evaluator.rb +237 -0
- data/lib/dependabot/python/poetry_subdependency_pin.rb +198 -0
- data/lib/dependabot/python/update_checker/pip_version_resolver.rb +4 -5
- data/lib/dependabot/python/update_checker/poetry_version_resolver.rb +37 -6
- data/lib/dependabot/python/update_checker.rb +14 -1
- metadata +6 -5
- data/lib/dependabot/python/update_checker/pip_version_resolver/marker_evaluator.rb +0 -241
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 658f2f5ddb3748a69e82dc4f8bf49d767a2c0dae9cbb8914a593d20ef5c07ea3
|
|
4
|
+
data.tar.gz: 68b2055158a4c722df9189946fbf1a187fcf30772d891c34656ee528d77f5301
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94b57c466aca413a45ac4cf7d467c3e6267e44e045a7d60d5c81d938f3ab272022b01cf678842350cc9ec22e6292b77698d562fc368061302581f3a60dd80eed
|
|
7
|
+
data.tar.gz: 804c699ef9572ecba46b5de36b11d25fdd06f869e514f411f3e82cf109ff829855a38ec333d523ee49d1dfa9e8e1270d8d50eb147f1839e86ad6dd54808be4c7
|
|
@@ -121,11 +121,24 @@ module Dependabot
|
|
|
121
121
|
new(PyprojectValueParser.object_hash(parsed, "pyproject.toml"))
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
+
POETRY_BUILD_BACKEND_PREFIX = "poetry.core"
|
|
125
|
+
|
|
124
126
|
sig { returns(T::Boolean) }
|
|
125
127
|
def poetry?
|
|
126
128
|
!poetry_root.nil?
|
|
127
129
|
end
|
|
128
130
|
|
|
131
|
+
# Poetry 2.0+ allows projects to declare dependencies using only PEP 621's
|
|
132
|
+
# [project.dependencies], with no [tool.poetry] table at all. We detect these
|
|
133
|
+
# projects via their declared build backend so they're still treated as Poetry
|
|
134
|
+
# projects (e.g. for lockfile parsing and dependency updates).
|
|
135
|
+
sig { returns(T::Boolean) }
|
|
136
|
+
def poetry_build_backend?
|
|
137
|
+
build_system = section(@data, "build-system", "build-system")
|
|
138
|
+
backend = build_system&.[]("build-backend")
|
|
139
|
+
backend.is_a?(String) && backend.start_with?(POETRY_BUILD_BACKEND_PREFIX)
|
|
140
|
+
end
|
|
141
|
+
|
|
129
142
|
sig { returns(T::Boolean) }
|
|
130
143
|
def project?
|
|
131
144
|
!section(@data, "project", "project").nil?
|
|
@@ -275,6 +288,32 @@ module Dependabot
|
|
|
275
288
|
!(keys.include?("git") && keys.include?("tag"))
|
|
276
289
|
end
|
|
277
290
|
|
|
291
|
+
# Keys come back as the author wrote them, which is what a caller has to match the file on.
|
|
292
|
+
sig { returns(T::Hash[String, T::Hash[Symbol, String]]) }
|
|
293
|
+
def uv_git_tag_sources
|
|
294
|
+
tool = section(@data, "tool", "tool")
|
|
295
|
+
uv = tool && section(tool, "uv", "tool.uv")
|
|
296
|
+
sources = uv && section(uv, "sources", "tool.uv.sources")
|
|
297
|
+
return {} unless sources
|
|
298
|
+
|
|
299
|
+
result = T.let({}, T::Hash[String, T::Hash[Symbol, String]])
|
|
300
|
+
sources.each do |name, config|
|
|
301
|
+
next unless config.is_a?(Hash)
|
|
302
|
+
|
|
303
|
+
entry = PyprojectValueParser.object_hash(config, "tool.uv.sources.#{name}")
|
|
304
|
+
url = entry["git"]
|
|
305
|
+
ref = entry["tag"]
|
|
306
|
+
next unless url.is_a?(String) && ref.is_a?(String)
|
|
307
|
+
|
|
308
|
+
result[name] = { url: url, ref: ref }
|
|
309
|
+
end
|
|
310
|
+
result
|
|
311
|
+
rescue TypeError
|
|
312
|
+
# A malformed table is not this method's business either: it runs for every PEP 621
|
|
313
|
+
# manifest, so raising would stop every dependency in the repository being updated.
|
|
314
|
+
{}
|
|
315
|
+
end
|
|
316
|
+
|
|
278
317
|
sig { params(key: String).returns(T::Array[String]) }
|
|
279
318
|
def workspace_globs(key)
|
|
280
319
|
tool = section(@data, "tool", "tool")
|
|
@@ -207,7 +207,19 @@ module Dependabot
|
|
|
207
207
|
|
|
208
208
|
sig { returns(T::Boolean) }
|
|
209
209
|
def using_poetry?
|
|
210
|
-
pyproject_document.poetry?
|
|
210
|
+
pyproject_document.poetry? || locked_poetry_build_backend?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# A declared Poetry build backend alone isn't proof the project is actively Poetry-managed;
|
|
214
|
+
# require a committed lockfile as evidence it's really locked by Poetry.
|
|
215
|
+
sig { returns(T::Boolean) }
|
|
216
|
+
def locked_poetry_build_backend?
|
|
217
|
+
pyproject_document.poetry_build_backend? && !lockfile.nil?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
sig { returns(T::Boolean) }
|
|
221
|
+
def locked_pep621_only_poetry?
|
|
222
|
+
!pyproject_document.poetry? && locked_poetry_build_backend?
|
|
211
223
|
end
|
|
212
224
|
|
|
213
225
|
sig { returns(T::Boolean) }
|
|
@@ -233,8 +245,9 @@ module Dependabot
|
|
|
233
245
|
sig { params(dep: PepDependency).returns(T::Boolean) }
|
|
234
246
|
def skip_pep621_dep?(dep)
|
|
235
247
|
# If a requirement has a `<` or `<=` marker then updating it is
|
|
236
|
-
# probably blocked. Ignore it
|
|
237
|
-
|
|
248
|
+
# probably blocked. Ignore it, unless Poetry has locked it: skipping would
|
|
249
|
+
# re-surface it from the lockfile as a requirement-less sub-dependency.
|
|
250
|
+
return true if dep.markers&.include?("<") && !locked_pep621_only_poetry?
|
|
238
251
|
|
|
239
252
|
# If no requirement, don't add it
|
|
240
253
|
requirement = dep.requirement
|
|
@@ -15,6 +15,7 @@ require "dependabot/python/name_normaliser"
|
|
|
15
15
|
require "dependabot/python/pip_compile_file_matcher"
|
|
16
16
|
require "dependabot/python/language_version_manager"
|
|
17
17
|
require "dependabot/python/package_manager"
|
|
18
|
+
require "dependabot/python/marker_evaluator"
|
|
18
19
|
|
|
19
20
|
module Dependabot
|
|
20
21
|
module Python
|
|
@@ -351,8 +352,8 @@ module Dependabot
|
|
|
351
352
|
|
|
352
353
|
version = python_raw_version
|
|
353
354
|
|
|
354
|
-
if marker.
|
|
355
|
-
!marker_satisfied?(marker, version)
|
|
355
|
+
if marker.match?(/\bpython(?:_full)?_version\b/)
|
|
356
|
+
!marker_evaluator.marker_satisfied?(marker: marker, python_version: version)
|
|
356
357
|
else
|
|
357
358
|
return true if marker.include?("<")
|
|
358
359
|
return false if marker.include?(">")
|
|
@@ -361,52 +362,9 @@ module Dependabot
|
|
|
361
362
|
end
|
|
362
363
|
end
|
|
363
364
|
|
|
364
|
-
sig {
|
|
365
|
-
def
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
# Explicitly define the type of result as T::Boolean
|
|
369
|
-
result = T.let(evaluate_condition(T.must(conditions.shift), python_version), T::Boolean)
|
|
370
|
-
|
|
371
|
-
until conditions.empty?
|
|
372
|
-
operator = conditions.shift
|
|
373
|
-
next_condition = T.must(conditions.shift)
|
|
374
|
-
next_result = evaluate_condition(next_condition, python_version)
|
|
375
|
-
|
|
376
|
-
result = if operator == "and"
|
|
377
|
-
result && next_result
|
|
378
|
-
else
|
|
379
|
-
result || next_result
|
|
380
|
-
end
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
result
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
sig do
|
|
387
|
-
params(
|
|
388
|
-
condition: String,
|
|
389
|
-
python_version: T.any(String, Integer, Gem::Version)
|
|
390
|
-
).returns(T::Boolean)
|
|
391
|
-
end
|
|
392
|
-
def evaluate_condition(condition, python_version)
|
|
393
|
-
operator, version = condition.match(/([<>=!]=?)\s*"?([\d.]+)"?/)&.captures
|
|
394
|
-
return false unless version
|
|
395
|
-
|
|
396
|
-
case operator
|
|
397
|
-
when "<"
|
|
398
|
-
Dependabot::Python::Version.new(python_version) < Dependabot::Python::Version.new(version)
|
|
399
|
-
when "<="
|
|
400
|
-
Dependabot::Python::Version.new(python_version) <= Dependabot::Python::Version.new(version)
|
|
401
|
-
when ">"
|
|
402
|
-
Dependabot::Python::Version.new(python_version) > Dependabot::Python::Version.new(version)
|
|
403
|
-
when ">="
|
|
404
|
-
Dependabot::Python::Version.new(python_version) >= Dependabot::Python::Version.new(version)
|
|
405
|
-
when "=="
|
|
406
|
-
Dependabot::Python::Version.new(python_version) == Dependabot::Python::Version.new(version)
|
|
407
|
-
else
|
|
408
|
-
false
|
|
409
|
-
end
|
|
365
|
+
sig { returns(MarkerEvaluator) }
|
|
366
|
+
def marker_evaluator
|
|
367
|
+
@marker_evaluator ||= T.let(MarkerEvaluator.new, T.nilable(MarkerEvaluator))
|
|
410
368
|
end
|
|
411
369
|
|
|
412
370
|
sig { returns(DependencySet) }
|
|
@@ -101,10 +101,9 @@ module Dependabot
|
|
|
101
101
|
).returns(T.nilable(String))
|
|
102
102
|
end
|
|
103
103
|
def replace_with_source_requirement(content, source_req, new_r, old_r)
|
|
104
|
-
|
|
105
|
-
return unless
|
|
104
|
+
declaration = find_declaration(content, declaration_regex(source_req))
|
|
105
|
+
return unless declaration
|
|
106
106
|
|
|
107
|
-
declaration = T.must(match[:declaration])
|
|
108
107
|
new_req_str = rewrite_pep508_requirement(
|
|
109
108
|
source_req,
|
|
110
109
|
T.must(old_r.requirement_string),
|
|
@@ -128,23 +127,44 @@ module Dependabot
|
|
|
128
127
|
old_req = T.must(old_r.requirement_string)
|
|
129
128
|
new_req = T.must(new_r.requirement_string)
|
|
130
129
|
|
|
131
|
-
|
|
132
|
-
return unless
|
|
133
|
-
|
|
134
|
-
declaration = T.must(match[:declaration])
|
|
135
|
-
return unless declaration.include?(old_req)
|
|
130
|
+
declaration = find_declaration(content, normalized_declaration_regex(old_req))
|
|
131
|
+
return unless declaration&.include?(old_req)
|
|
136
132
|
|
|
137
133
|
content.sub(declaration, declaration.sub(old_req, new_req))
|
|
138
134
|
end
|
|
139
135
|
|
|
136
|
+
# The regexes stop after the requirement. The closing quote and any environment marker are
|
|
137
|
+
# checked here, since matching an unbounded marker in the regex itself can backtrack badly.
|
|
138
|
+
sig { params(content: String, prefix_regex: Regexp).returns(T.nilable(String)) }
|
|
139
|
+
def find_declaration(content, prefix_regex)
|
|
140
|
+
position = 0
|
|
141
|
+
while (match = content.match(prefix_regex, position))
|
|
142
|
+
finish = declaration_end(content, match.end(0))
|
|
143
|
+
return content[match.begin(0)...finish] if finish
|
|
144
|
+
|
|
145
|
+
position = match.begin(0) + 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
sig { params(content: String, start: Integer).returns(T.nilable(Integer)) }
|
|
150
|
+
def declaration_end(content, start)
|
|
151
|
+
tail = content[start..].to_s
|
|
152
|
+
quote = tail.index(/["']/)
|
|
153
|
+
return unless quote
|
|
154
|
+
|
|
155
|
+
trailing = tail[0, quote].to_s
|
|
156
|
+
start + quote + 1 if trailing.empty? || trailing.lstrip.start_with?(";")
|
|
157
|
+
end
|
|
158
|
+
|
|
140
159
|
sig { params(old_req: String).returns(Regexp) }
|
|
141
160
|
def declaration_regex(old_req)
|
|
142
|
-
|
|
161
|
+
spacing = extras_pattern.empty? ? "\\s*" : "\\s*#{extras_pattern}\\s*"
|
|
162
|
+
/["']#{escape}#{spacing}#{Regexp.escape(old_req)}/mi
|
|
143
163
|
end
|
|
144
164
|
|
|
145
165
|
sig { params(old_req: String).returns(Regexp) }
|
|
146
166
|
def normalized_declaration_regex(old_req)
|
|
147
|
-
/
|
|
167
|
+
/["']#{escape}#{extras_pattern}#{Regexp.escape(old_req)}/mi
|
|
148
168
|
end
|
|
149
169
|
|
|
150
170
|
sig { returns(String) }
|
|
@@ -11,9 +11,11 @@ require "dependabot/python/version"
|
|
|
11
11
|
require "dependabot/python/requirement"
|
|
12
12
|
require "dependabot/python/file_parser/python_requirement_parser"
|
|
13
13
|
require "dependabot/python/file_updater"
|
|
14
|
+
require "dependabot/python/file_parser/pyproject_document"
|
|
14
15
|
require "dependabot/python/native_helpers"
|
|
15
16
|
require "dependabot/python/name_normaliser"
|
|
16
17
|
require "dependabot/python/poetry_plugin_installer"
|
|
18
|
+
require "dependabot/python/poetry_subdependency_pin"
|
|
17
19
|
require "dependabot/package/release_cooldown_options"
|
|
18
20
|
|
|
19
21
|
module Dependabot
|
|
@@ -29,6 +31,7 @@ module Dependabot
|
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
class FileUpdater
|
|
34
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
32
35
|
class PoetryFileUpdater
|
|
33
36
|
require_relative "pyproject_preparer"
|
|
34
37
|
require_relative "poetry_file_updater/pep621_updater"
|
|
@@ -249,6 +252,8 @@ module Dependabot
|
|
|
249
252
|
create_declaration_at_new_version!(poetry_object, dep)
|
|
250
253
|
end
|
|
251
254
|
end
|
|
255
|
+
elsif FileParser::PyprojectDocument.from_content(pyproject_content).poetry_build_backend?
|
|
256
|
+
pin_pep621_subdependencies!(pyproject_object)
|
|
252
257
|
end
|
|
253
258
|
|
|
254
259
|
# Freeze PEP 621 project.dependencies and project.optional-dependencies
|
|
@@ -259,6 +264,21 @@ module Dependabot
|
|
|
259
264
|
TomlRB.dump(pyproject_object)
|
|
260
265
|
end
|
|
261
266
|
|
|
267
|
+
# freeze_pep621_deps! skips dependency groups and a sub-dependency has no declaration, so without
|
|
268
|
+
# this Poetry could lock either to a newer release than the one selected.
|
|
269
|
+
sig { params(pyproject_object: T::Hash[String, T.untyped]).void }
|
|
270
|
+
def pin_pep621_subdependencies!(pyproject_object)
|
|
271
|
+
dependencies.each do |dep|
|
|
272
|
+
next if git_dependency_being_updated?(dep)
|
|
273
|
+
next unless dep.version
|
|
274
|
+
|
|
275
|
+
PoetrySubdependencyPin.new(lockfile, dep.name).set_requirement!(pyproject_object, "==#{dep.version}")
|
|
276
|
+
rescue PoetrySubdependencyPin::ScopeNotReproducible
|
|
277
|
+
# The update checker declines these; skipping keeps a direct run from promoting the package.
|
|
278
|
+
next
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
262
282
|
sig { params(pyproject_content: String).returns(String) }
|
|
263
283
|
def update_python_requirement(pyproject_content)
|
|
264
284
|
PyprojectPreparer.new(pyproject_content: pyproject_content)
|
|
@@ -8,6 +8,7 @@ require "dependabot/python/file_parser"
|
|
|
8
8
|
require "dependabot/python/file_updater"
|
|
9
9
|
require "dependabot/python/authed_url_builder"
|
|
10
10
|
require "dependabot/python/name_normaliser"
|
|
11
|
+
require "dependabot/python/requirement"
|
|
11
12
|
require "securerandom"
|
|
12
13
|
|
|
13
14
|
module Dependabot
|
|
@@ -122,8 +123,8 @@ module Dependabot
|
|
|
122
123
|
pyproject_object = TomlRB.parse(@pyproject_content)
|
|
123
124
|
|
|
124
125
|
if (python_specification = pyproject_object.dig("tool", "poetry", "dependencies", "python"))
|
|
125
|
-
|
|
126
|
-
unless
|
|
126
|
+
python_requirements = Python::Requirement.requirements_array(python_specification)
|
|
127
|
+
unless python_requirements.empty? || python_requirements.any? { |req| req.satisfied_by?(requirement) }
|
|
127
128
|
pyproject_object["tool"]["poetry"]["dependencies"]["python"] = "~#{requirement}"
|
|
128
129
|
end
|
|
129
130
|
end
|
|
@@ -148,8 +149,9 @@ module Dependabot
|
|
|
148
149
|
pyproject_object = TomlRB.parse(pyproject_content)
|
|
149
150
|
poetry_object = pyproject_object.dig("tool", "poetry")
|
|
150
151
|
|
|
151
|
-
return pyproject_content unless poetry_object
|
|
152
|
+
return pyproject_content unless poetry_object || locked_poetry_build_backend?
|
|
152
153
|
|
|
154
|
+
poetry_object ||= {}
|
|
153
155
|
excluded_names = dependencies.map(&:name) + ["python"]
|
|
154
156
|
|
|
155
157
|
Dependabot::Python::FileParser::PyprojectFilesParser::POETRY_DEPENDENCY_TYPES.each do |key|
|
|
@@ -176,6 +178,11 @@ module Dependabot
|
|
|
176
178
|
sig { returns(T.nilable(Dependabot::DependencyFile)) }
|
|
177
179
|
attr_reader :lockfile
|
|
178
180
|
|
|
181
|
+
sig { returns(T::Boolean) }
|
|
182
|
+
def locked_poetry_build_backend?
|
|
183
|
+
!lockfile.nil? && FileParser::PyprojectDocument.from_content(pyproject_content).poetry_build_backend?
|
|
184
|
+
end
|
|
185
|
+
|
|
179
186
|
sig { params(deps_hash: T::Hash[String, T.untyped], dep_name: String).void }
|
|
180
187
|
def freeze_poetry_dep!(deps_hash, dep_name)
|
|
181
188
|
details = locked_details(dep_name)
|
|
@@ -230,6 +237,9 @@ module Dependabot
|
|
|
230
237
|
dep_name = normalise(T.must(match[1]))
|
|
231
238
|
next if excluded_names.include?(dep_name)
|
|
232
239
|
|
|
240
|
+
# One pin can't suit every Python branch of a package locked at several versions.
|
|
241
|
+
next if locked_entry_count(dep_name) > 1
|
|
242
|
+
|
|
233
243
|
locked_details = locked_details(dep_name)
|
|
234
244
|
next unless (locked_version = locked_details&.fetch("version"))
|
|
235
245
|
|
|
@@ -243,6 +253,11 @@ module Dependabot
|
|
|
243
253
|
.find { |d| d["name"] == normalise(dep_name) }
|
|
244
254
|
end
|
|
245
255
|
|
|
256
|
+
sig { params(dep_name: String).returns(Integer) }
|
|
257
|
+
def locked_entry_count(dep_name)
|
|
258
|
+
parsed_lockfile.fetch("package").count { |d| d["name"] == normalise(dep_name) }
|
|
259
|
+
end
|
|
260
|
+
|
|
246
261
|
sig { params(name: String).returns(String) }
|
|
247
262
|
def normalise(name)
|
|
248
263
|
NameNormaliser.normalise(name)
|
|
@@ -5,6 +5,7 @@ require "toml-rb"
|
|
|
5
5
|
require "dependabot/file_updaters"
|
|
6
6
|
require "dependabot/file_updaters/base"
|
|
7
7
|
require "dependabot/shared_helpers"
|
|
8
|
+
require "dependabot/python/file_parser/pyproject_document"
|
|
8
9
|
require "sorbet-runtime"
|
|
9
10
|
|
|
10
11
|
module Dependabot
|
|
@@ -154,8 +155,7 @@ module Dependabot
|
|
|
154
155
|
|
|
155
156
|
return false unless poetry_lock
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
!build_backend.nil? && build_backend.start_with?("poetry.core")
|
|
158
|
+
FileParser::PyprojectDocument.from_content(T.must(pyproject&.content)).poetry_build_backend?
|
|
159
159
|
end
|
|
160
160
|
|
|
161
161
|
sig { returns(T.nilable(Dependabot::DependencyFile)) }
|
|
@@ -17,11 +17,11 @@ module Dependabot
|
|
|
17
17
|
# Note: uv ecosystem aliases this class, so updates here apply to both ecosystems.
|
|
18
18
|
PRE_INSTALLED_PYTHON_VERSIONS_RAW = %w(
|
|
19
19
|
3.15.2
|
|
20
|
-
3.14.
|
|
21
|
-
3.13.
|
|
22
|
-
3.12.
|
|
23
|
-
3.11.
|
|
24
|
-
3.10.
|
|
20
|
+
3.14.10
|
|
21
|
+
3.13.18
|
|
22
|
+
3.12.16
|
|
23
|
+
3.11.18
|
|
24
|
+
3.10.22
|
|
25
25
|
).freeze
|
|
26
26
|
|
|
27
27
|
PRE_INSTALLED_PYTHON_VERSIONS = T.let(
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "dependabot/python/version"
|
|
5
|
+
|
|
6
|
+
module Dependabot
|
|
7
|
+
module Python
|
|
8
|
+
class MarkerEvaluator
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(requirement_string: String).returns([T.nilable(String), T.nilable(String)]) }
|
|
12
|
+
def split_requirement_and_marker(requirement_string)
|
|
13
|
+
separator_index = T.let(nil, T.nilable(Integer))
|
|
14
|
+
in_single_quote = T.let(false, T::Boolean)
|
|
15
|
+
in_double_quote = T.let(false, T::Boolean)
|
|
16
|
+
|
|
17
|
+
requirement_string.each_char.with_index do |char, index|
|
|
18
|
+
in_single_quote, in_double_quote, quote_toggled =
|
|
19
|
+
toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
20
|
+
next if quote_toggled
|
|
21
|
+
next if in_single_quote || in_double_quote
|
|
22
|
+
next unless char == ";"
|
|
23
|
+
|
|
24
|
+
separator_index = index
|
|
25
|
+
break
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return [requirement_string.strip, nil] if separator_index.nil?
|
|
29
|
+
|
|
30
|
+
requirement_part = requirement_string[0...separator_index]
|
|
31
|
+
marker_part = requirement_string[(separator_index + 1)..]
|
|
32
|
+
|
|
33
|
+
[requirement_part&.strip, marker_part&.strip]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(marker: String, python_version: String).returns(T::Boolean) }
|
|
37
|
+
def marker_satisfied?(marker:, python_version:)
|
|
38
|
+
evaluate_marker_expression(marker, python_version)
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
# If we cannot safely parse a python marker, treat it as applicable.
|
|
41
|
+
# This avoids silently skipping transitive constraints that may break installs.
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
sig { params(expression: String, python_version: String).returns(T::Boolean) }
|
|
48
|
+
def evaluate_marker_expression(expression, python_version)
|
|
49
|
+
expr = strip_wrapping_parentheses(expression.strip)
|
|
50
|
+
|
|
51
|
+
or_parts = split_top_level(expr, "or")
|
|
52
|
+
return or_parts.any? { |part| evaluate_marker_expression(part, python_version) } if or_parts.length > 1
|
|
53
|
+
|
|
54
|
+
and_parts = split_top_level(expr, "and")
|
|
55
|
+
if and_parts.length > 1
|
|
56
|
+
return and_parts.all? do |part|
|
|
57
|
+
evaluate_marker_expression(part, python_version) || !python_marker?(part)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
not_expression = strip_top_level_not(expr)
|
|
62
|
+
if not_expression
|
|
63
|
+
return false unless python_marker?(not_expression)
|
|
64
|
+
|
|
65
|
+
return !evaluate_marker_expression(not_expression, python_version)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
evaluate_python_version_condition(expr, python_version, default: python_marker?(expr))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sig { params(expression: String).returns(T.nilable(String)) }
|
|
72
|
+
def strip_top_level_not(expression)
|
|
73
|
+
return nil unless expression.start_with?("not")
|
|
74
|
+
return nil unless word_at?(expression, 0, "not")
|
|
75
|
+
|
|
76
|
+
remaining = expression[3..]&.strip
|
|
77
|
+
return nil if remaining.nil? || remaining.empty?
|
|
78
|
+
|
|
79
|
+
remaining
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { params(expression: String).returns(T::Boolean) }
|
|
83
|
+
def python_marker?(expression)
|
|
84
|
+
expression.match?(/\bpython(?:_full)?_version\b/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { params(expression: String).returns(String) }
|
|
88
|
+
def strip_wrapping_parentheses(expression)
|
|
89
|
+
expr = expression
|
|
90
|
+
while expr.start_with?("(") && expr.end_with?(")")
|
|
91
|
+
inner = expr[1...-1].to_s.strip
|
|
92
|
+
break unless balanced_parentheses?(inner)
|
|
93
|
+
|
|
94
|
+
expr = inner
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
expr
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sig { params(expression: String).returns(T::Boolean) }
|
|
101
|
+
def balanced_parentheses?(expression)
|
|
102
|
+
depth = T.let(0, Integer)
|
|
103
|
+
in_single_quote = T.let(false, T::Boolean)
|
|
104
|
+
in_double_quote = T.let(false, T::Boolean)
|
|
105
|
+
|
|
106
|
+
expression.each_char do |char|
|
|
107
|
+
in_single_quote, in_double_quote, quote_toggled =
|
|
108
|
+
toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
109
|
+
next if quote_toggled
|
|
110
|
+
next if in_single_quote || in_double_quote
|
|
111
|
+
|
|
112
|
+
depth += 1 if char == "("
|
|
113
|
+
depth -= 1 if char == ")"
|
|
114
|
+
return false if depth.negative?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
depth.zero? && !in_single_quote && !in_double_quote
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
sig do
|
|
121
|
+
params(
|
|
122
|
+
char: String,
|
|
123
|
+
in_single_quote: T::Boolean,
|
|
124
|
+
in_double_quote: T::Boolean
|
|
125
|
+
).returns([T::Boolean, T::Boolean, T::Boolean])
|
|
126
|
+
end
|
|
127
|
+
def toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
128
|
+
return [!in_single_quote, in_double_quote, true] if char == "'" && !in_double_quote
|
|
129
|
+
|
|
130
|
+
return [in_single_quote, !in_double_quote, true] if char == '"' && !in_single_quote
|
|
131
|
+
|
|
132
|
+
[in_single_quote, in_double_quote, false]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { params(expression: String, operator: String).returns(T::Array[String]) }
|
|
136
|
+
def split_top_level(expression, operator)
|
|
137
|
+
parts = T.let([], T::Array[String])
|
|
138
|
+
token = T.let(+"", String)
|
|
139
|
+
depth = T.let(0, Integer)
|
|
140
|
+
in_single_quote = T.let(false, T::Boolean)
|
|
141
|
+
in_double_quote = T.let(false, T::Boolean)
|
|
142
|
+
i = T.let(0, Integer)
|
|
143
|
+
|
|
144
|
+
while i < expression.length
|
|
145
|
+
char = T.must(expression[i])
|
|
146
|
+
|
|
147
|
+
in_single_quote, in_double_quote, quote_toggled =
|
|
148
|
+
toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
149
|
+
if quote_toggled
|
|
150
|
+
token << char
|
|
151
|
+
i += 1
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
depth = update_depth_for_unquoted_char(char, depth, in_single_quote, in_double_quote)
|
|
156
|
+
|
|
157
|
+
if depth.zero? && !in_single_quote && !in_double_quote && word_at?(expression, i, operator)
|
|
158
|
+
parts << token.strip
|
|
159
|
+
token = +""
|
|
160
|
+
i += operator.length
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
token << char
|
|
165
|
+
i += 1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
parts << token.strip
|
|
169
|
+
parts
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
sig do
|
|
173
|
+
params(
|
|
174
|
+
char: String,
|
|
175
|
+
depth: Integer,
|
|
176
|
+
in_single_quote: T::Boolean,
|
|
177
|
+
in_double_quote: T::Boolean
|
|
178
|
+
).returns(Integer)
|
|
179
|
+
end
|
|
180
|
+
def update_depth_for_unquoted_char(char, depth, in_single_quote, in_double_quote)
|
|
181
|
+
return depth if in_single_quote || in_double_quote
|
|
182
|
+
|
|
183
|
+
depth += 1 if char == "("
|
|
184
|
+
depth -= 1 if char == ")"
|
|
185
|
+
depth
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
sig { params(expression: String, index: Integer, word: String).returns(T::Boolean) }
|
|
189
|
+
def word_at?(expression, index, word)
|
|
190
|
+
return false unless expression[index, word.length] == word
|
|
191
|
+
|
|
192
|
+
before = index.zero? ? " " : T.must(expression[index - 1])
|
|
193
|
+
after_index = index + word.length
|
|
194
|
+
after = after_index >= expression.length ? " " : T.must(expression[after_index])
|
|
195
|
+
|
|
196
|
+
word_boundary?(before) && word_boundary?(after)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
sig { params(char: String).returns(T::Boolean) }
|
|
200
|
+
def word_boundary?(char)
|
|
201
|
+
!!(char =~ /[^A-Za-z0-9_]/)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
sig do
|
|
205
|
+
params(
|
|
206
|
+
condition: T.nilable(String),
|
|
207
|
+
python_version: String,
|
|
208
|
+
default: T::Boolean
|
|
209
|
+
).returns(T::Boolean)
|
|
210
|
+
end
|
|
211
|
+
def evaluate_python_version_condition(condition, python_version, default:)
|
|
212
|
+
return default if condition.nil?
|
|
213
|
+
|
|
214
|
+
candidate = strip_wrapping_parentheses(condition.strip)
|
|
215
|
+
match = candidate.match(/\Apython(?:_full)?_version\s*(<=|>=|<|>|==|!=)\s*['\"]([^'\"]+)['\"]\z/)
|
|
216
|
+
return default unless match
|
|
217
|
+
|
|
218
|
+
operator = T.must(match[1])
|
|
219
|
+
version = T.must(match[2])
|
|
220
|
+
lhs = Dependabot::Python::Version.new(python_version)
|
|
221
|
+
rhs = Dependabot::Python::Version.new(version)
|
|
222
|
+
|
|
223
|
+
case operator
|
|
224
|
+
when "<" then lhs < rhs
|
|
225
|
+
when "<=" then lhs <= rhs
|
|
226
|
+
when ">" then lhs > rhs
|
|
227
|
+
when ">=" then lhs >= rhs
|
|
228
|
+
when "==" then lhs == rhs
|
|
229
|
+
when "!=" then lhs != rhs
|
|
230
|
+
else false
|
|
231
|
+
end
|
|
232
|
+
rescue ArgumentError
|
|
233
|
+
true
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
require "toml-rb"
|
|
6
|
+
require "dependabot/dependency_file"
|
|
7
|
+
require "dependabot/python/name_normaliser"
|
|
8
|
+
require "dependabot/python/file_updater/pyproject_preparer"
|
|
9
|
+
|
|
10
|
+
module Dependabot
|
|
11
|
+
module Python
|
|
12
|
+
# Pins a sub-dependency of a PEP 621-only project in a scratch manifest. The pin keeps
|
|
13
|
+
# the package's locked markers, so Poetry records the same install conditions; an unconditional pin
|
|
14
|
+
# would promote an extra-only or platform-specific package into every install.
|
|
15
|
+
#
|
|
16
|
+
# A package locked at several versions (one per Python branch) is declined: a single pin can't
|
|
17
|
+
# satisfy every branch.
|
|
18
|
+
class PoetrySubdependencyPin
|
|
19
|
+
extend T::Sig
|
|
20
|
+
|
|
21
|
+
EXTRA_NAME = /extra\s*==\s*"([^"]+)"/
|
|
22
|
+
private_constant :EXTRA_NAME
|
|
23
|
+
|
|
24
|
+
# Raised when a pin can't reproduce the package's install conditions, so the update is declined
|
|
25
|
+
# rather than risk locking a version outside its intended scope.
|
|
26
|
+
class ScopeNotReproducible < StandardError; end
|
|
27
|
+
|
|
28
|
+
# A string, or a table such as PEP 735's `{ include-group = "..." }`.
|
|
29
|
+
DeclarationEntry = T.type_alias { T.any(String, T::Hash[String, String]) }
|
|
30
|
+
private_constant :DeclarationEntry
|
|
31
|
+
|
|
32
|
+
class LockedEntry < T::Struct
|
|
33
|
+
const :optional, T::Boolean
|
|
34
|
+
const :groups, T::Array[String]
|
|
35
|
+
const :markers, T::Hash[String, T::Array[String]]
|
|
36
|
+
|
|
37
|
+
extend T::Sig
|
|
38
|
+
|
|
39
|
+
sig { params(group: String).returns(T::Array[String]) }
|
|
40
|
+
def markers_for(group)
|
|
41
|
+
markers.fetch(group, [])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_constant :LockedEntry
|
|
45
|
+
|
|
46
|
+
sig { params(lockfile: T.nilable(Dependabot::DependencyFile), name: String).void }
|
|
47
|
+
def initialize(lockfile, name)
|
|
48
|
+
@lockfile = lockfile
|
|
49
|
+
@name = name
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Updates every declaration of the dependency in place; a sub-dependency isn't declared anywhere,
|
|
53
|
+
# so it is pinned instead to force Poetry's solver to consider the target version.
|
|
54
|
+
# rubocop:disable-next Sorbet/ForbidTUntyped
|
|
55
|
+
sig { params(pyproject_object: T::Hash[String, T.untyped], requirement: String).void }
|
|
56
|
+
def set_requirement!(pyproject_object, requirement)
|
|
57
|
+
project_object = pyproject_object["project"]
|
|
58
|
+
return unless project_object
|
|
59
|
+
|
|
60
|
+
ensure_single_version!
|
|
61
|
+
return if declaration_arrays(pyproject_object).map { |arr| update_declarations!(arr, requirement) }.any?
|
|
62
|
+
|
|
63
|
+
pin!(pyproject_object, "#{@name}#{requirement}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A package locked at several versions has per-marker scopes that one target version can't reproduce.
|
|
67
|
+
sig { void }
|
|
68
|
+
def ensure_single_version!
|
|
69
|
+
raise ScopeNotReproducible, "#{@name} is locked at several versions" if entries.size > 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Adds a pin per place the package is locked (main, extras, other groups), each keeping its locked
|
|
73
|
+
# markers. Raises ScopeNotReproducible for a package locked at several versions, or an optional
|
|
74
|
+
# package with no extras in its markers.
|
|
75
|
+
# rubocop:disable-next Sorbet/ForbidTUntyped
|
|
76
|
+
sig { params(pyproject_object: T::Hash[String, T.untyped], pin: String).void }
|
|
77
|
+
def pin!(pyproject_object, pin)
|
|
78
|
+
project_object = pyproject_object["project"]
|
|
79
|
+
return unless project_object
|
|
80
|
+
|
|
81
|
+
main_pin, extra_pins, group_pins = pins_for(pin)
|
|
82
|
+
|
|
83
|
+
(project_object["dependencies"] ||= []) << main_pin if main_pin
|
|
84
|
+
append_pins!(project_object["optional-dependencies"] ||= {}, extra_pins)
|
|
85
|
+
append_pins!(pyproject_object["dependency-groups"] ||= {}, group_pins) if group_pins.any?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
sig { params(table: T::Hash[String, T::Array[DeclarationEntry]], pins: T::Hash[String, String]).void }
|
|
91
|
+
def append_pins!(table, pins)
|
|
92
|
+
pins.each { |key, pin| (table[declared_key(table, key)] ||= []) << pin }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns [main_pin, extra_pins, group_pins]. Raises ScopeNotReproducible for a package locked at
|
|
96
|
+
# several versions, or an optional package with no extras in its markers (legacy lockfile format):
|
|
97
|
+
# pinning it unconditionally would promote it into the main install.
|
|
98
|
+
sig { params(pin: String).returns([T.nilable(String), T::Hash[String, String], T::Hash[String, String]]) }
|
|
99
|
+
def pins_for(pin)
|
|
100
|
+
ensure_single_version!
|
|
101
|
+
|
|
102
|
+
entry = entries.first
|
|
103
|
+
return [pin, {}, {}] unless entry
|
|
104
|
+
|
|
105
|
+
group_pins = (entry.groups - ["main"]).to_h { |group| [group, scoped_pin(pin, entry, group)] }
|
|
106
|
+
return [nil, {}, group_pins] unless entry.groups.include?("main")
|
|
107
|
+
return [scoped_pin(pin, entry, "main"), {}, group_pins] unless entry.optional
|
|
108
|
+
|
|
109
|
+
[nil, extra_pins(pin, entry), group_pins]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
sig { params(pin: String, entry: LockedEntry, group: String).returns(String) }
|
|
113
|
+
def scoped_pin(pin, entry, group)
|
|
114
|
+
marker = entry.markers_for(group).first
|
|
115
|
+
marker ? "#{pin}; #{marker}" : pin
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The lock holds normalised extra and group names, which may differ from how the project spells them.
|
|
119
|
+
sig { params(table: T::Hash[String, T::Array[DeclarationEntry]], name: String).returns(String) }
|
|
120
|
+
def declared_key(table, name)
|
|
121
|
+
table.keys.find { |key| normalise_key(key) == normalise_key(name) } || name
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
sig { params(name: String).returns(String) }
|
|
125
|
+
def normalise_key(name)
|
|
126
|
+
name.downcase.gsub(/[-_.]+/, "-")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# rubocop:disable-next Sorbet/ForbidTUntyped
|
|
130
|
+
sig { params(pyproject_object: T::Hash[String, T.untyped]).returns(T::Array[T::Array[DeclarationEntry]]) }
|
|
131
|
+
def declaration_arrays(pyproject_object)
|
|
132
|
+
project = pyproject_object.fetch("project", {})
|
|
133
|
+
tables = [project["optional-dependencies"], pyproject_object["dependency-groups"]].compact
|
|
134
|
+
[project["dependencies"], *tables.flat_map(&:values)].grep(Array)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
sig { params(declarations: T::Array[DeclarationEntry], requirement: String).returns(T::Boolean) }
|
|
138
|
+
def update_declarations!(declarations, requirement)
|
|
139
|
+
updated = T.let(false, T::Boolean)
|
|
140
|
+
|
|
141
|
+
declarations.each_with_index do |entry, index|
|
|
142
|
+
next unless entry.is_a?(String)
|
|
143
|
+
|
|
144
|
+
match = entry.match(Python::FileUpdater::PyprojectPreparer::PEP508_PREFIX)
|
|
145
|
+
next unless match && NameNormaliser.normalise(T.must(match[:name])) == @name
|
|
146
|
+
|
|
147
|
+
prefix = T.must(match[:prefix])
|
|
148
|
+
head, separator, tail = entry[prefix.length..].to_s.partition(";")
|
|
149
|
+
marker = separator.empty? ? "" : "#{head[head.rstrip.length..]};#{tail}"
|
|
150
|
+
declarations[index] = "#{prefix}#{requirement}#{marker}"
|
|
151
|
+
updated = true
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
updated
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
sig { returns(T::Array[LockedEntry]) }
|
|
158
|
+
def entries
|
|
159
|
+
@entries ||= T.let(locked_entries, T.nilable(T::Array[LockedEntry]))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
sig { returns(T::Array[LockedEntry]) }
|
|
163
|
+
def locked_entries
|
|
164
|
+
return [] unless @lockfile
|
|
165
|
+
|
|
166
|
+
TomlRB.parse(T.must(@lockfile.content)).fetch("package", []).filter_map do |entry|
|
|
167
|
+
next unless entry["name"] && NameNormaliser.normalise(entry["name"]) == @name
|
|
168
|
+
|
|
169
|
+
# Legacy lockfiles record no groups; those entries are treated as main.
|
|
170
|
+
groups = Array(entry["groups"])
|
|
171
|
+
groups = ["main"] if groups.empty?
|
|
172
|
+
LockedEntry.new(optional: entry["optional"] == true, groups: groups, markers: locked_markers(entry, groups))
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Markers are a single string, or a table keyed by group.
|
|
177
|
+
# rubocop:disable-next Sorbet/ForbidTUntyped
|
|
178
|
+
sig do
|
|
179
|
+
params(entry: T::Hash[String, T.untyped], groups: T::Array[String]).returns(T::Hash[String, T::Array[String]])
|
|
180
|
+
end
|
|
181
|
+
def locked_markers(entry, groups)
|
|
182
|
+
markers = entry["markers"]
|
|
183
|
+
return markers.transform_values { |marker| ["(#{marker})"] } if markers.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
groups.to_h { |group| [group, markers ? ["(#{markers})"] : []] }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
sig { params(pin: String, entry: LockedEntry).returns(T::Hash[String, String]) }
|
|
189
|
+
def extra_pins(pin, entry)
|
|
190
|
+
marker = entry.markers_for("main").first.to_s
|
|
191
|
+
extras = marker.scan(EXTRA_NAME).flatten.uniq
|
|
192
|
+
raise ScopeNotReproducible, "#{@name} is optional but has no recorded extras" if extras.empty?
|
|
193
|
+
|
|
194
|
+
extras.to_h { |extra| [extra, "#{pin}; #{marker}"] }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -14,6 +14,7 @@ require "dependabot/python/update_checker"
|
|
|
14
14
|
require "dependabot/python/update_checker/latest_version_finder"
|
|
15
15
|
require "dependabot/python/file_parser/python_requirement_parser"
|
|
16
16
|
require "dependabot/python/file_parser/pyproject_document"
|
|
17
|
+
require "dependabot/python/marker_evaluator"
|
|
17
18
|
|
|
18
19
|
module Dependabot
|
|
19
20
|
module Python
|
|
@@ -24,8 +25,6 @@ module Dependabot
|
|
|
24
25
|
class PipVersionResolver
|
|
25
26
|
extend T::Sig
|
|
26
27
|
|
|
27
|
-
require_relative "pip_version_resolver/marker_evaluator"
|
|
28
|
-
|
|
29
28
|
sig do
|
|
30
29
|
params(
|
|
31
30
|
dependency: Dependabot::Dependency,
|
|
@@ -56,7 +55,7 @@ module Dependabot
|
|
|
56
55
|
@latest_version_finder = T.let(nil, T.nilable(LatestVersionFinder))
|
|
57
56
|
@python_requirement_parser = T.let(nil, T.nilable(FileParser::PythonRequirementParser))
|
|
58
57
|
@language_version_manager = T.let(nil, T.nilable(LanguageVersionManager))
|
|
59
|
-
@marker_evaluator = T.let(nil, T.nilable(MarkerEvaluator))
|
|
58
|
+
@marker_evaluator = T.let(nil, T.nilable(Dependabot::Python::MarkerEvaluator))
|
|
60
59
|
@registry_json_urls = T.let(nil, T.nilable(T::Array[String]))
|
|
61
60
|
@transitive_requirements_cache = T.let({}, T::Hash[String, T::Array[String]])
|
|
62
61
|
@transitive_requirement_available_cache = T.let({}, T::Hash[String, T::Boolean])
|
|
@@ -138,9 +137,9 @@ module Dependabot
|
|
|
138
137
|
)
|
|
139
138
|
end
|
|
140
139
|
|
|
141
|
-
sig { returns(MarkerEvaluator) }
|
|
140
|
+
sig { returns(Dependabot::Python::MarkerEvaluator) }
|
|
142
141
|
def marker_evaluator
|
|
143
|
-
@marker_evaluator ||= MarkerEvaluator.new
|
|
142
|
+
@marker_evaluator ||= Dependabot::Python::MarkerEvaluator.new
|
|
144
143
|
end
|
|
145
144
|
|
|
146
145
|
sig { params(candidate: Dependabot::Version).returns(T::Boolean) }
|
|
@@ -19,6 +19,7 @@ require "dependabot/python/native_helpers"
|
|
|
19
19
|
require "dependabot/python/authed_url_builder"
|
|
20
20
|
require "dependabot/python/name_normaliser"
|
|
21
21
|
require "dependabot/python/poetry_plugin_installer"
|
|
22
|
+
require "dependabot/python/poetry_subdependency_pin"
|
|
22
23
|
|
|
23
24
|
module Dependabot
|
|
24
25
|
module Python
|
|
@@ -144,6 +145,8 @@ module Dependabot
|
|
|
144
145
|
updated_lockfile = TomlRB.parse(updated_lockfile)
|
|
145
146
|
|
|
146
147
|
fetch_version_from_parsed_lockfile(updated_lockfile)
|
|
148
|
+
rescue PoetrySubdependencyPin::ScopeNotReproducible
|
|
149
|
+
nil
|
|
147
150
|
rescue SharedHelpers::HelperSubprocessFailed => e
|
|
148
151
|
handle_poetry_errors(e)
|
|
149
152
|
end
|
|
@@ -315,11 +318,33 @@ module Dependabot
|
|
|
315
318
|
|
|
316
319
|
sig { params(pyproject_content: String, updated_requirement: T.nilable(String)).returns(String) }
|
|
317
320
|
def set_target_dependency_req(pyproject_content, updated_requirement)
|
|
318
|
-
|
|
321
|
+
unless updated_requirement
|
|
322
|
+
# Poetry's lock result for a package locked at several versions can't be read back as one version.
|
|
323
|
+
if pep621_only_poetry_project?(pyproject_content)
|
|
324
|
+
PoetrySubdependencyPin.new(poetry_lock, dependency.name).ensure_single_version!
|
|
325
|
+
end
|
|
326
|
+
return pyproject_content
|
|
327
|
+
end
|
|
319
328
|
|
|
320
329
|
pyproject_object = TomlRB.parse(pyproject_content)
|
|
321
330
|
poetry_object = pyproject_object.dig("tool", "poetry")
|
|
322
331
|
|
|
332
|
+
if poetry_object
|
|
333
|
+
set_poetry_target_dependency_req(poetry_object, updated_requirement)
|
|
334
|
+
elsif pep621_only_poetry_project?(pyproject_content)
|
|
335
|
+
set_pep621_target_dependency_req(pyproject_object, updated_requirement)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
TomlRB.dump(pyproject_object)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
sig { params(pyproject_content: String).returns(T::Boolean) }
|
|
342
|
+
def pep621_only_poetry_project?(pyproject_content)
|
|
343
|
+
!poetry_lock.nil? && FileParser::PyprojectDocument.from_content(pyproject_content).poetry_build_backend?
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
sig { params(poetry_object: T::Hash[String, T.untyped], updated_requirement: String).void }
|
|
347
|
+
def set_poetry_target_dependency_req(poetry_object, updated_requirement)
|
|
323
348
|
Dependabot::Python::FileParser::PyprojectFilesParser::POETRY_DEPENDENCY_TYPES.each do |type|
|
|
324
349
|
dependencies = poetry_object[type]
|
|
325
350
|
next unless dependencies
|
|
@@ -333,12 +358,18 @@ module Dependabot
|
|
|
333
358
|
end
|
|
334
359
|
|
|
335
360
|
# If this is a sub-dependency, add the new requirement
|
|
336
|
-
|
|
337
|
-
poetry_object[subdep_type] ||= {}
|
|
338
|
-
poetry_object[subdep_type][dependency.name] = updated_requirement
|
|
339
|
-
end
|
|
361
|
+
return if dependency.requirements.find { |r| r.file == T.must(pyproject).name }
|
|
340
362
|
|
|
341
|
-
|
|
363
|
+
poetry_object[subdep_type] ||= {}
|
|
364
|
+
poetry_object[subdep_type][dependency.name] = updated_requirement
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# PEP 621 projects have no [tool.poetry] table to pin dependencies in. This file is only ever used
|
|
368
|
+
# as scratch input to run `poetry lock`, so it never reaches the real manifest.
|
|
369
|
+
sig { params(pyproject_object: T::Hash[String, T.untyped], updated_requirement: String).void }
|
|
370
|
+
def set_pep621_target_dependency_req(pyproject_object, updated_requirement)
|
|
371
|
+
PoetrySubdependencyPin.new(poetry_lock, dependency.name)
|
|
372
|
+
.set_requirement!(pyproject_object, updated_requirement)
|
|
342
373
|
end
|
|
343
374
|
|
|
344
375
|
sig { params(toml_node: T.nilable(T::Hash[String, T.untyped]), requirement: String).void }
|
|
@@ -73,6 +73,8 @@ module Dependabot
|
|
|
73
73
|
@latest_resolvable_version_with_no_unlock ||= T.let(
|
|
74
74
|
if resolver_type == :requirements
|
|
75
75
|
resolver.latest_resolvable_version_with_no_unlock
|
|
76
|
+
elsif resolver_type == :poetry
|
|
77
|
+
resolver.latest_resolvable_version(requirement: nil)
|
|
76
78
|
else
|
|
77
79
|
resolver.latest_resolvable_version(
|
|
78
80
|
requirement: current_requirement_string
|
|
@@ -84,6 +86,12 @@ module Dependabot
|
|
|
84
86
|
|
|
85
87
|
sig { override.returns(T.nilable(Gem::Version)) }
|
|
86
88
|
def lowest_security_fix_version
|
|
89
|
+
# A git dependency's version is whatever the lockfile records for that package, not a commit
|
|
90
|
+
# sha, so `existing_version_is_sha?` does not exclude it from security updates the way it does
|
|
91
|
+
# elsewhere. Its index has no bearing on the pinned ref: answering with a version from there
|
|
92
|
+
# would title the pull request with a fix the diff does not apply.
|
|
93
|
+
return nil if git_dependency?
|
|
94
|
+
|
|
87
95
|
latest_version_finder.lowest_security_fix_version
|
|
88
96
|
end
|
|
89
97
|
|
|
@@ -398,7 +406,12 @@ module Dependabot
|
|
|
398
406
|
|
|
399
407
|
sig { returns(T::Boolean) }
|
|
400
408
|
def poetry_based?
|
|
401
|
-
updating_pyproject? && pyproject_document.poetry?
|
|
409
|
+
updating_pyproject? && (pyproject_document.poetry? || locked_poetry_build_backend?)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
sig { returns(T::Boolean) }
|
|
413
|
+
def locked_poetry_build_backend?
|
|
414
|
+
!poetry_lock.nil? && pyproject_document.poetry_build_backend?
|
|
402
415
|
end
|
|
403
416
|
|
|
404
417
|
sig { returns(T::Boolean) }
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dependabot-python
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.404.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dependabot
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.404.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.404.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: debug
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -302,6 +302,7 @@ files:
|
|
|
302
302
|
- lib/dependabot/python/file_updater/setup_file_sanitizer.rb
|
|
303
303
|
- lib/dependabot/python/language.rb
|
|
304
304
|
- lib/dependabot/python/language_version_manager.rb
|
|
305
|
+
- lib/dependabot/python/marker_evaluator.rb
|
|
305
306
|
- lib/dependabot/python/metadata_finder.rb
|
|
306
307
|
- lib/dependabot/python/name_normaliser.rb
|
|
307
308
|
- lib/dependabot/python/native_helpers.rb
|
|
@@ -314,6 +315,7 @@ files:
|
|
|
314
315
|
- lib/dependabot/python/pip_compile_file_matcher.rb
|
|
315
316
|
- lib/dependabot/python/pipenv_runner.rb
|
|
316
317
|
- lib/dependabot/python/poetry_plugin_installer.rb
|
|
318
|
+
- lib/dependabot/python/poetry_subdependency_pin.rb
|
|
317
319
|
- lib/dependabot/python/requirement.rb
|
|
318
320
|
- lib/dependabot/python/requirement_parser.rb
|
|
319
321
|
- lib/dependabot/python/shared_file_fetcher.rb
|
|
@@ -321,7 +323,6 @@ files:
|
|
|
321
323
|
- lib/dependabot/python/update_checker/latest_version_finder.rb
|
|
322
324
|
- lib/dependabot/python/update_checker/pip_compile_version_resolver.rb
|
|
323
325
|
- lib/dependabot/python/update_checker/pip_version_resolver.rb
|
|
324
|
-
- lib/dependabot/python/update_checker/pip_version_resolver/marker_evaluator.rb
|
|
325
326
|
- lib/dependabot/python/update_checker/pipenv_version_resolver.rb
|
|
326
327
|
- lib/dependabot/python/update_checker/poetry_version_resolver.rb
|
|
327
328
|
- lib/dependabot/python/update_checker/requirements_updater.rb
|
|
@@ -331,7 +332,7 @@ licenses:
|
|
|
331
332
|
- MIT
|
|
332
333
|
metadata:
|
|
333
334
|
bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
|
|
334
|
-
changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.
|
|
335
|
+
changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.404.0
|
|
335
336
|
rdoc_options: []
|
|
336
337
|
require_paths:
|
|
337
338
|
- lib
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
# typed: strong
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "dependabot/python/update_checker/pip_version_resolver"
|
|
5
|
-
|
|
6
|
-
module Dependabot
|
|
7
|
-
module Python
|
|
8
|
-
class UpdateChecker
|
|
9
|
-
class PipVersionResolver
|
|
10
|
-
class MarkerEvaluator
|
|
11
|
-
extend T::Sig
|
|
12
|
-
|
|
13
|
-
sig { params(requirement_string: String).returns([T.nilable(String), T.nilable(String)]) }
|
|
14
|
-
def split_requirement_and_marker(requirement_string)
|
|
15
|
-
separator_index = T.let(nil, T.nilable(Integer))
|
|
16
|
-
in_single_quote = T.let(false, T::Boolean)
|
|
17
|
-
in_double_quote = T.let(false, T::Boolean)
|
|
18
|
-
|
|
19
|
-
requirement_string.each_char.with_index do |char, index|
|
|
20
|
-
in_single_quote, in_double_quote, quote_toggled =
|
|
21
|
-
toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
22
|
-
next if quote_toggled
|
|
23
|
-
next if in_single_quote || in_double_quote
|
|
24
|
-
next unless char == ";"
|
|
25
|
-
|
|
26
|
-
separator_index = index
|
|
27
|
-
break
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
return [requirement_string.strip, nil] if separator_index.nil?
|
|
31
|
-
|
|
32
|
-
requirement_part = requirement_string[0...separator_index]
|
|
33
|
-
marker_part = requirement_string[(separator_index + 1)..]
|
|
34
|
-
|
|
35
|
-
[requirement_part&.strip, marker_part&.strip]
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
sig { params(marker: String, python_version: String).returns(T::Boolean) }
|
|
39
|
-
def marker_satisfied?(marker:, python_version:)
|
|
40
|
-
evaluate_marker_expression(marker, python_version)
|
|
41
|
-
rescue ArgumentError
|
|
42
|
-
# If we cannot safely parse a python marker, treat it as applicable.
|
|
43
|
-
# This avoids silently skipping transitive constraints that may break installs.
|
|
44
|
-
true
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
sig { params(expression: String, python_version: String).returns(T::Boolean) }
|
|
50
|
-
def evaluate_marker_expression(expression, python_version)
|
|
51
|
-
expr = strip_wrapping_parentheses(expression.strip)
|
|
52
|
-
|
|
53
|
-
or_parts = split_top_level(expr, "or")
|
|
54
|
-
return or_parts.any? { |part| evaluate_marker_expression(part, python_version) } if or_parts.length > 1
|
|
55
|
-
|
|
56
|
-
and_parts = split_top_level(expr, "and")
|
|
57
|
-
if and_parts.length > 1
|
|
58
|
-
return and_parts.all? do |part|
|
|
59
|
-
evaluate_marker_expression(part, python_version) || !python_marker?(part)
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
not_expression = strip_top_level_not(expr)
|
|
64
|
-
if not_expression
|
|
65
|
-
return false unless python_marker?(not_expression)
|
|
66
|
-
|
|
67
|
-
return !evaluate_marker_expression(not_expression, python_version)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
evaluate_python_version_condition(expr, python_version, default: python_marker?(expr))
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
sig { params(expression: String).returns(T.nilable(String)) }
|
|
74
|
-
def strip_top_level_not(expression)
|
|
75
|
-
return nil unless expression.start_with?("not")
|
|
76
|
-
return nil unless word_at?(expression, 0, "not")
|
|
77
|
-
|
|
78
|
-
remaining = expression[3..]&.strip
|
|
79
|
-
return nil if remaining.nil? || remaining.empty?
|
|
80
|
-
|
|
81
|
-
remaining
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
sig { params(expression: String).returns(T::Boolean) }
|
|
85
|
-
def python_marker?(expression)
|
|
86
|
-
expression.match?(/\bpython(?:_full)?_version\b/)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
sig { params(expression: String).returns(String) }
|
|
90
|
-
def strip_wrapping_parentheses(expression)
|
|
91
|
-
expr = expression
|
|
92
|
-
while expr.start_with?("(") && expr.end_with?(")")
|
|
93
|
-
inner = expr[1...-1].to_s.strip
|
|
94
|
-
break unless balanced_parentheses?(inner)
|
|
95
|
-
|
|
96
|
-
expr = inner
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
expr
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
sig { params(expression: String).returns(T::Boolean) }
|
|
103
|
-
def balanced_parentheses?(expression)
|
|
104
|
-
depth = T.let(0, Integer)
|
|
105
|
-
in_single_quote = T.let(false, T::Boolean)
|
|
106
|
-
in_double_quote = T.let(false, T::Boolean)
|
|
107
|
-
|
|
108
|
-
expression.each_char do |char|
|
|
109
|
-
in_single_quote, in_double_quote, quote_toggled =
|
|
110
|
-
toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
111
|
-
next if quote_toggled
|
|
112
|
-
next if in_single_quote || in_double_quote
|
|
113
|
-
|
|
114
|
-
depth += 1 if char == "("
|
|
115
|
-
depth -= 1 if char == ")"
|
|
116
|
-
return false if depth.negative?
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
depth.zero? && !in_single_quote && !in_double_quote
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
sig do
|
|
123
|
-
params(
|
|
124
|
-
char: String,
|
|
125
|
-
in_single_quote: T::Boolean,
|
|
126
|
-
in_double_quote: T::Boolean
|
|
127
|
-
).returns([T::Boolean, T::Boolean, T::Boolean])
|
|
128
|
-
end
|
|
129
|
-
def toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
130
|
-
return [!in_single_quote, in_double_quote, true] if char == "'" && !in_double_quote
|
|
131
|
-
|
|
132
|
-
return [in_single_quote, !in_double_quote, true] if char == '"' && !in_single_quote
|
|
133
|
-
|
|
134
|
-
[in_single_quote, in_double_quote, false]
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
sig { params(expression: String, operator: String).returns(T::Array[String]) }
|
|
138
|
-
def split_top_level(expression, operator)
|
|
139
|
-
parts = T.let([], T::Array[String])
|
|
140
|
-
token = T.let(+"", String)
|
|
141
|
-
depth = T.let(0, Integer)
|
|
142
|
-
in_single_quote = T.let(false, T::Boolean)
|
|
143
|
-
in_double_quote = T.let(false, T::Boolean)
|
|
144
|
-
i = T.let(0, Integer)
|
|
145
|
-
|
|
146
|
-
while i < expression.length
|
|
147
|
-
char = T.must(expression[i])
|
|
148
|
-
|
|
149
|
-
in_single_quote, in_double_quote, quote_toggled =
|
|
150
|
-
toggle_quote_state(char, in_single_quote, in_double_quote)
|
|
151
|
-
if quote_toggled
|
|
152
|
-
token << char
|
|
153
|
-
i += 1
|
|
154
|
-
next
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
depth = update_depth_for_unquoted_char(char, depth, in_single_quote, in_double_quote)
|
|
158
|
-
|
|
159
|
-
if depth.zero? && !in_single_quote && !in_double_quote && word_at?(expression, i, operator)
|
|
160
|
-
parts << token.strip
|
|
161
|
-
token = +""
|
|
162
|
-
i += operator.length
|
|
163
|
-
next
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
token << char
|
|
167
|
-
i += 1
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
parts << token.strip
|
|
171
|
-
parts
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
sig do
|
|
175
|
-
params(
|
|
176
|
-
char: String,
|
|
177
|
-
depth: Integer,
|
|
178
|
-
in_single_quote: T::Boolean,
|
|
179
|
-
in_double_quote: T::Boolean
|
|
180
|
-
).returns(Integer)
|
|
181
|
-
end
|
|
182
|
-
def update_depth_for_unquoted_char(char, depth, in_single_quote, in_double_quote)
|
|
183
|
-
return depth if in_single_quote || in_double_quote
|
|
184
|
-
|
|
185
|
-
depth += 1 if char == "("
|
|
186
|
-
depth -= 1 if char == ")"
|
|
187
|
-
depth
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
sig { params(expression: String, index: Integer, word: String).returns(T::Boolean) }
|
|
191
|
-
def word_at?(expression, index, word)
|
|
192
|
-
return false unless expression[index, word.length] == word
|
|
193
|
-
|
|
194
|
-
before = index.zero? ? " " : T.must(expression[index - 1])
|
|
195
|
-
after_index = index + word.length
|
|
196
|
-
after = after_index >= expression.length ? " " : T.must(expression[after_index])
|
|
197
|
-
|
|
198
|
-
word_boundary?(before) && word_boundary?(after)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
sig { params(char: String).returns(T::Boolean) }
|
|
202
|
-
def word_boundary?(char)
|
|
203
|
-
!!(char =~ /[^A-Za-z0-9_]/)
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
sig do
|
|
207
|
-
params(
|
|
208
|
-
condition: T.nilable(String),
|
|
209
|
-
python_version: String,
|
|
210
|
-
default: T::Boolean
|
|
211
|
-
).returns(T::Boolean)
|
|
212
|
-
end
|
|
213
|
-
def evaluate_python_version_condition(condition, python_version, default:)
|
|
214
|
-
return default if condition.nil?
|
|
215
|
-
|
|
216
|
-
candidate = strip_wrapping_parentheses(condition.strip)
|
|
217
|
-
match = candidate.match(/\Apython(?:_full)?_version\s*(<=|>=|<|>|==|!=)\s*['\"]([^'\"]+)['\"]\z/)
|
|
218
|
-
return default unless match
|
|
219
|
-
|
|
220
|
-
operator = T.must(match[1])
|
|
221
|
-
version = T.must(match[2])
|
|
222
|
-
lhs = Dependabot::Python::Version.new(python_version)
|
|
223
|
-
rhs = Dependabot::Python::Version.new(version)
|
|
224
|
-
|
|
225
|
-
case operator
|
|
226
|
-
when "<" then lhs < rhs
|
|
227
|
-
when "<=" then lhs <= rhs
|
|
228
|
-
when ">" then lhs > rhs
|
|
229
|
-
when ">=" then lhs >= rhs
|
|
230
|
-
when "==" then lhs == rhs
|
|
231
|
-
when "!=" then lhs != rhs
|
|
232
|
-
else false
|
|
233
|
-
end
|
|
234
|
-
rescue ArgumentError
|
|
235
|
-
true
|
|
236
|
-
end
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
end
|