dependabot-python 0.369.0 → 0.370.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/helpers/lib/parser.py +26 -0
- data/helpers/test/fixtures/pep621_spaced_specifiers.toml +10 -0
- data/helpers/test/test_parser.py +81 -0
- data/lib/dependabot/python/dependency_grapher/lockfile_generator.rb +13 -0
- data/lib/dependabot/python/file_parser/pyproject_files_parser.rb +42 -11
- data/lib/dependabot/python/file_parser.rb +21 -1
- data/lib/dependabot/python/file_updater/poetry_file_updater/pep621_updater.rb +162 -0
- data/lib/dependabot/python/file_updater/poetry_file_updater.rb +48 -70
- data/lib/dependabot/python/file_updater/pyproject_preparer.rb +55 -52
- data/lib/dependabot/python/package_manager.rb +16 -0
- data/lib/dependabot/python/poetry_plugin_installer.rb +95 -0
- data/lib/dependabot/python/update_checker/poetry_version_resolver.rb +13 -0
- data/lib/dependabot/python/update_checker/requirements_updater.rb +86 -15
- data/lib/dependabot/python/update_checker.rb +6 -2
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67c1d482dda68f38a7bbc7ea74f0d4cc206debd5c3f0704905911b05f04e4b2f
|
|
4
|
+
data.tar.gz: bb39f9c0055c2ab9a86d8a2138676c69fbac55e52c45a7a261b7af5b94c28042
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d0def98050153cafb8f4ce61c39495fd8b24f82f4ca8a4d62f158b86bfa9a695016520b92c4b54025d1df9a5c98be6eb5bba723e54846e3c449feee86d7fa28
|
|
7
|
+
data.tar.gz: 715ae0908b35409adb0a67afb7aa5c6ca1cbb6036ae596719a8ef5afbe3b326571850a4b026820541e56c1ad70afd9a960cf7a796cf4a1b1986f2e4bb26531b6
|
data/helpers/lib/parser.py
CHANGED
|
@@ -32,6 +32,30 @@ def parse_pep621_pep735_dependencies(pyproject_path):
|
|
|
32
32
|
next(iter(specifier_set)).operator in {"==", "==="}):
|
|
33
33
|
return next(iter(specifier_set)).version
|
|
34
34
|
|
|
35
|
+
def original_requirement_from_entry(entry, req):
|
|
36
|
+
"""Extract the original requirement string.
|
|
37
|
+
|
|
38
|
+
The packaging library normalizes specifiers
|
|
39
|
+
(removes spaces, reorders operators), but we
|
|
40
|
+
need the original so the file updater regex
|
|
41
|
+
can match the file content.
|
|
42
|
+
"""
|
|
43
|
+
# Strip the name (and any extras like [filecache]) from the start
|
|
44
|
+
remainder = entry[len(req.name):].strip()
|
|
45
|
+
|
|
46
|
+
# Strip extras bracket if present, e.g. [filecache]
|
|
47
|
+
if remainder.startswith("["):
|
|
48
|
+
close = remainder.index("]")
|
|
49
|
+
remainder = remainder[close + 1:].strip()
|
|
50
|
+
|
|
51
|
+
# Strip markers from the end, e.g. "; python_version < '3.4'"
|
|
52
|
+
if req.marker:
|
|
53
|
+
marker_pos = remainder.find(";")
|
|
54
|
+
if marker_pos != -1:
|
|
55
|
+
remainder = remainder[:marker_pos].strip()
|
|
56
|
+
|
|
57
|
+
return remainder
|
|
58
|
+
|
|
35
59
|
def parse_requirement(entry, pyproject_path, requirement_type=None):
|
|
36
60
|
try:
|
|
37
61
|
req = Requirement(entry)
|
|
@@ -45,6 +69,8 @@ def parse_pep621_pep735_dependencies(pyproject_path):
|
|
|
45
69
|
"markers": str(req.marker) or None,
|
|
46
70
|
"file": pyproject_path,
|
|
47
71
|
"requirement": str(req.specifier),
|
|
72
|
+
"source_requirement":
|
|
73
|
+
original_requirement_from_entry(entry, req),
|
|
48
74
|
"extras": sorted(list(req.extras)),
|
|
49
75
|
"requirement_type": requirement_type,
|
|
50
76
|
}
|
data/helpers/test/test_parser.py
CHANGED
|
@@ -182,3 +182,84 @@ class TestEdgeCases:
|
|
|
182
182
|
# Both groups should be processed without infinite recursion
|
|
183
183
|
assert "requests" in names
|
|
184
184
|
assert "flask" in names
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# source_requirement — preserves original specifier string from the TOML
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
class TestSourceRequirement:
|
|
191
|
+
def test_simple_specifier_preserves_order(self):
|
|
192
|
+
deps = parse("pep621_dependencies.toml")
|
|
193
|
+
requests = find_dep(deps, "requests")
|
|
194
|
+
# Original: "requests>=2.13.0,<3.0" — order preserved
|
|
195
|
+
assert requests["source_requirement"] == ">=2.13.0,<3.0"
|
|
196
|
+
# Normalized by packaging: "<3.0,>=2.13.0" (alphabetical by operator)
|
|
197
|
+
assert requests["requirement"] == "<3.0,>=2.13.0"
|
|
198
|
+
|
|
199
|
+
def test_exact_version(self):
|
|
200
|
+
deps = parse("pep621_dependencies.toml")
|
|
201
|
+
urllib3 = find_dep(deps, "urllib3")
|
|
202
|
+
assert urllib3["source_requirement"] == "==1.26.0"
|
|
203
|
+
|
|
204
|
+
def test_extras_stripped_from_source(self):
|
|
205
|
+
deps = parse("pep621_extras.toml")
|
|
206
|
+
cc = find_dep(deps, "cachecontrol")
|
|
207
|
+
assert cc["source_requirement"] == ">=0.14.0"
|
|
208
|
+
|
|
209
|
+
def test_markers_stripped_from_source(self):
|
|
210
|
+
deps = parse("pep621_markers.toml")
|
|
211
|
+
requests = find_dep(deps, "requests")
|
|
212
|
+
assert requests["source_requirement"] == ">=2.13.0"
|
|
213
|
+
|
|
214
|
+
def test_no_version_specifier(self):
|
|
215
|
+
deps = parse("pep621_no_version.toml")
|
|
216
|
+
requests = find_dep(deps, "requests")
|
|
217
|
+
assert requests["source_requirement"] == ""
|
|
218
|
+
|
|
219
|
+
def test_multiple_extras_stripped(self):
|
|
220
|
+
deps = parse("pep621_multiple_extras.toml")
|
|
221
|
+
boto3 = find_dep(deps, "boto3")
|
|
222
|
+
assert boto3["source_requirement"] == ">=1.28.0"
|
|
223
|
+
|
|
224
|
+
def test_arbitrary_equality(self):
|
|
225
|
+
deps = parse("pep621_arbitrary_equality.toml")
|
|
226
|
+
numpy = find_dep(deps, "numpy")
|
|
227
|
+
assert numpy["source_requirement"] == "===1.24.0rc1"
|
|
228
|
+
|
|
229
|
+
def test_spaced_specifiers_preserved(self):
|
|
230
|
+
deps = parse("pep621_spaced_specifiers.toml")
|
|
231
|
+
requests = find_dep(deps, "requests")
|
|
232
|
+
# Spaces are preserved from the original entry
|
|
233
|
+
assert requests["source_requirement"] == ">= 2.13.0, < 3.0"
|
|
234
|
+
|
|
235
|
+
def test_spaced_exact_version_preserved(self):
|
|
236
|
+
deps = parse("pep621_spaced_specifiers.toml")
|
|
237
|
+
urllib3 = find_dep(deps, "urllib3")
|
|
238
|
+
assert urllib3["source_requirement"] == "== 1.26.0"
|
|
239
|
+
|
|
240
|
+
def test_spaced_extras_and_specifier(self):
|
|
241
|
+
deps = parse("pep621_spaced_specifiers.toml")
|
|
242
|
+
cc = find_dep(deps, "cachecontrol")
|
|
243
|
+
assert cc["source_requirement"] == ">= 0.14.0"
|
|
244
|
+
|
|
245
|
+
def test_spaced_specifier_with_markers(self):
|
|
246
|
+
deps = parse("pep621_spaced_specifiers.toml")
|
|
247
|
+
idna = find_dep(deps, "idna")
|
|
248
|
+
# Markers stripped, original spacing kept
|
|
249
|
+
assert idna["source_requirement"] == ">= 2.5"
|
|
250
|
+
|
|
251
|
+
def test_optional_deps_with_spaces(self):
|
|
252
|
+
deps = parse("pep621_dependencies.toml")
|
|
253
|
+
pysocks = find_dep(deps, "PySocks")
|
|
254
|
+
# Original: "PySocks >= 1.5.6, != 1.5.7, < 2"
|
|
255
|
+
assert pysocks["source_requirement"] == ">= 1.5.6, != 1.5.7, < 2"
|
|
256
|
+
|
|
257
|
+
def test_build_system_source_requirement(self):
|
|
258
|
+
deps = parse("pep621_dependencies.toml")
|
|
259
|
+
setuptools = find_dep(deps, "setuptools")
|
|
260
|
+
assert setuptools["source_requirement"] == ">=68.0"
|
|
261
|
+
|
|
262
|
+
def test_dependency_group_source_requirement(self):
|
|
263
|
+
deps = parse("pep735_dependency_groups.toml")
|
|
264
|
+
pytest_dep = find_dep(deps, "pytest")
|
|
265
|
+
assert pytest_dep["source_requirement"] == "==7.1.3"
|
|
@@ -7,6 +7,7 @@ require "dependabot/dependency_file"
|
|
|
7
7
|
require "dependabot/shared_helpers"
|
|
8
8
|
require "dependabot/python/file_parser/python_requirement_parser"
|
|
9
9
|
require "dependabot/python/language_version_manager"
|
|
10
|
+
require "dependabot/python/poetry_plugin_installer"
|
|
10
11
|
|
|
11
12
|
module Dependabot
|
|
12
13
|
module Python
|
|
@@ -33,6 +34,10 @@ module Dependabot
|
|
|
33
34
|
SharedHelpers.with_git_configured(credentials: credentials) do
|
|
34
35
|
write_temporary_files
|
|
35
36
|
language_version_manager.install_required_python
|
|
37
|
+
|
|
38
|
+
# Install any required Poetry plugins declared in pyproject.toml
|
|
39
|
+
poetry_plugin_installer.install_required_plugins
|
|
40
|
+
|
|
36
41
|
run_poetry_lock
|
|
37
42
|
read_generated_lockfile
|
|
38
43
|
end
|
|
@@ -119,6 +124,14 @@ module Dependabot
|
|
|
119
124
|
)
|
|
120
125
|
end
|
|
121
126
|
|
|
127
|
+
sig { returns(PoetryPluginInstaller) }
|
|
128
|
+
def poetry_plugin_installer
|
|
129
|
+
@poetry_plugin_installer ||= T.let(
|
|
130
|
+
PoetryPluginInstaller.from_dependency_files(dependency_files),
|
|
131
|
+
T.nilable(PoetryPluginInstaller)
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
122
135
|
sig { params(error: SharedHelpers::HelperSubprocessFailed).void }
|
|
123
136
|
def handle_generation_error(error)
|
|
124
137
|
Dependabot.logger.error(
|
|
@@ -25,6 +25,7 @@ module Dependabot
|
|
|
25
25
|
sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).void }
|
|
26
26
|
def initialize(dependency_files:)
|
|
27
27
|
@dependency_files = dependency_files
|
|
28
|
+
@dynamic_fields = T.let(nil, T.nilable(T::Array[String]))
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
sig { returns(Dependabot::FileParsers::Base::DependencySet) }
|
|
@@ -84,16 +85,7 @@ module Dependabot
|
|
|
84
85
|
return dependencies if using_pdm?
|
|
85
86
|
|
|
86
87
|
parse_pep621_pep735_dependencies.each do |dep|
|
|
87
|
-
|
|
88
|
-
# probably blocked. Ignore it.
|
|
89
|
-
next if dep["markers"]&.include?("<")
|
|
90
|
-
|
|
91
|
-
# If no requirement, don't add it
|
|
92
|
-
next if dep["requirement"].empty?
|
|
93
|
-
|
|
94
|
-
# Skip build-system.requires dependencies when using Poetry
|
|
95
|
-
# Poetry manages its own build system dependencies
|
|
96
|
-
next if using_poetry? && dep["requirement_type"] == "build-system.requires"
|
|
88
|
+
next if skip_pep621_dep?(dep)
|
|
97
89
|
|
|
98
90
|
dependencies <<
|
|
99
91
|
Dependency.new(
|
|
@@ -106,7 +98,9 @@ module Dependabot
|
|
|
106
98
|
groups: [dep["requirement_type"]].compact
|
|
107
99
|
}],
|
|
108
100
|
package_manager: "pip",
|
|
109
|
-
metadata: extras_metadata(dep["extras"])
|
|
101
|
+
metadata: extras_metadata(dep["extras"]).merge(
|
|
102
|
+
source_requirement: dep["source_requirement"]
|
|
103
|
+
).compact
|
|
110
104
|
)
|
|
111
105
|
end
|
|
112
106
|
|
|
@@ -229,6 +223,43 @@ module Dependabot
|
|
|
229
223
|
using_pep621? && pdm_lock
|
|
230
224
|
end
|
|
231
225
|
|
|
226
|
+
sig { returns(T::Array[String]) }
|
|
227
|
+
def dynamic_fields
|
|
228
|
+
@dynamic_fields ||= parsed_pyproject.dig("project", "dynamic") || []
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
sig { params(dep: T::Hash[String, T.untyped]).returns(T::Boolean) }
|
|
232
|
+
def skip_pep621_dep?(dep)
|
|
233
|
+
# If a requirement has a `<` or `<=` marker then updating it is
|
|
234
|
+
# probably blocked. Ignore it.
|
|
235
|
+
return true if dep["markers"]&.include?("<")
|
|
236
|
+
|
|
237
|
+
# If no requirement, don't add it
|
|
238
|
+
return true if dep["requirement"].empty?
|
|
239
|
+
|
|
240
|
+
# Skip build-system.requires dependencies when using Poetry
|
|
241
|
+
# Poetry manages its own build system dependencies
|
|
242
|
+
return true if using_poetry? && dep["requirement_type"] == "build-system.requires"
|
|
243
|
+
|
|
244
|
+
# When dependencies or optional-dependencies are listed in project.dynamic,
|
|
245
|
+
# they are managed by the build backend (e.g. Poetry) — skip the PEP 621 path
|
|
246
|
+
dynamic_pep621_dep?(dep["requirement_type"])
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
sig { params(requirement_type: T.nilable(String)).returns(T::Boolean) }
|
|
250
|
+
def dynamic_pep621_dep?(requirement_type)
|
|
251
|
+
return false unless using_poetry?
|
|
252
|
+
return false unless requirement_type
|
|
253
|
+
|
|
254
|
+
if requirement_type == "dependencies"
|
|
255
|
+
dynamic_fields.include?("dependencies")
|
|
256
|
+
elsif parsed_pyproject.dig("project", "optional-dependencies")&.key?(requirement_type)
|
|
257
|
+
dynamic_fields.include?("optional-dependencies")
|
|
258
|
+
else
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
232
263
|
# Create a DependencySet where each element has no requirement. Any
|
|
233
264
|
# requirements will be added when combining the DependencySet with
|
|
234
265
|
# other DependencySets.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "toml-rb"
|
|
4
5
|
require "dependabot/dependency"
|
|
5
6
|
require "dependabot/file_parsers"
|
|
6
7
|
require "dependabot/file_parsers/base"
|
|
@@ -110,7 +111,13 @@ module Dependabot
|
|
|
110
111
|
|
|
111
112
|
return PipenvPackageManager.new(T.must(detect_pipenv_version)) if detect_pipenv_version
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
poetry_version = detect_poetry_version
|
|
115
|
+
if poetry_version
|
|
116
|
+
return PoetryPackageManager.new(
|
|
117
|
+
poetry_version,
|
|
118
|
+
requires_poetry_version_constraint
|
|
119
|
+
)
|
|
120
|
+
end
|
|
114
121
|
|
|
115
122
|
return PipCompilePackageManager.new(T.must(detect_pipcompile_version)) if detect_pipcompile_version
|
|
116
123
|
|
|
@@ -135,6 +142,19 @@ module Dependabot
|
|
|
135
142
|
nil
|
|
136
143
|
end
|
|
137
144
|
|
|
145
|
+
sig { returns(T.nilable(Dependabot::Python::Requirement)) }
|
|
146
|
+
def requires_poetry_version_constraint
|
|
147
|
+
return nil unless pyproject&.content
|
|
148
|
+
|
|
149
|
+
parsed = TomlRB.parse(T.must(pyproject).content)
|
|
150
|
+
constraint = parsed.dig("tool", "poetry", "requires-poetry")
|
|
151
|
+
return nil unless constraint.is_a?(String) && !constraint.strip.empty?
|
|
152
|
+
|
|
153
|
+
Dependabot::Python::Requirement.new(constraint.strip)
|
|
154
|
+
rescue TomlRB::ParseError, TomlRB::ValueOverwriteError, Gem::Requirement::BadRequirementError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
138
158
|
# Detects the version of pip-compile. If the version cannot be detected, it returns nil
|
|
139
159
|
sig { returns(T.nilable(String)) }
|
|
140
160
|
def detect_pipcompile_version
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
require "dependabot/python/file_updater/poetry_file_updater"
|
|
6
|
+
|
|
7
|
+
module Dependabot
|
|
8
|
+
module Python
|
|
9
|
+
class FileUpdater
|
|
10
|
+
class PoetryFileUpdater
|
|
11
|
+
class Pep621Updater
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(dep: Dependabot::Dependency).void }
|
|
15
|
+
def initialize(dep:)
|
|
16
|
+
@dep = dep
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig do
|
|
20
|
+
params(
|
|
21
|
+
content: String,
|
|
22
|
+
new_r: T::Hash[Symbol, T.untyped],
|
|
23
|
+
old_r: T::Hash[Symbol, T.untyped]
|
|
24
|
+
).returns(T.nilable(String))
|
|
25
|
+
end
|
|
26
|
+
def replace(content, new_r, old_r)
|
|
27
|
+
source_req = dep.metadata[:source_requirement]
|
|
28
|
+
|
|
29
|
+
if source_req
|
|
30
|
+
replace_with_source_requirement(content, source_req, new_r, old_r)
|
|
31
|
+
else
|
|
32
|
+
replace_with_normalized_requirement(content, new_r, old_r)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(source_req: String, old_req: String, new_req: String).returns(String) }
|
|
37
|
+
def rewrite_pep508_requirement(source_req, old_req, new_req)
|
|
38
|
+
old_specifiers = parse_specifiers(old_req)
|
|
39
|
+
new_specifiers = parse_specifiers(new_req)
|
|
40
|
+
|
|
41
|
+
old_versions_by_op = group_versions_by_operator(old_specifiers)
|
|
42
|
+
new_versions_by_op = group_versions_by_operator(new_specifiers)
|
|
43
|
+
|
|
44
|
+
replacements = T.let([], T::Array[T::Hash[Symbol, String]])
|
|
45
|
+
new_versions_by_op.each do |operator, new_versions|
|
|
46
|
+
old_versions = old_versions_by_op[operator]
|
|
47
|
+
next unless old_versions
|
|
48
|
+
next unless old_versions.length == new_versions.length
|
|
49
|
+
|
|
50
|
+
old_versions.zip(new_versions).each do |old_version, new_version|
|
|
51
|
+
next if old_version == new_version
|
|
52
|
+
|
|
53
|
+
replacements << {
|
|
54
|
+
operator: operator,
|
|
55
|
+
old_version: old_version,
|
|
56
|
+
new_version: T.must(new_version)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
result = source_req.dup
|
|
62
|
+
replacements.each do |replacement|
|
|
63
|
+
op = Regexp.escape(T.must(replacement[:operator]))
|
|
64
|
+
ver = Regexp.escape(T.must(replacement[:old_version]))
|
|
65
|
+
result = result.sub(/(#{op}\s*)#{ver}/, "\\1#{replacement[:new_version]}")
|
|
66
|
+
end
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { params(specifiers: T::Array[T::Hash[Symbol, String]]).returns(T::Hash[String, T::Array[String]]) }
|
|
71
|
+
def group_versions_by_operator(specifiers)
|
|
72
|
+
specifiers.each_with_object(
|
|
73
|
+
T.let({}, T::Hash[String, T::Array[String]])
|
|
74
|
+
) do |specifier, grouped_versions|
|
|
75
|
+
operator = T.must(specifier[:operator])
|
|
76
|
+
version = T.must(specifier[:version])
|
|
77
|
+
|
|
78
|
+
grouped_versions[operator] ||= []
|
|
79
|
+
T.must(grouped_versions[operator]) << version
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sig { params(req: String).returns(T::Array[T::Hash[Symbol, String]]) }
|
|
84
|
+
def parse_specifiers(req)
|
|
85
|
+
req.scan(/([!<>=~]+)\s*([^\s,]+)/).map do |op, ver|
|
|
86
|
+
{ operator: op, version: ver }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
sig { returns(Dependabot::Dependency) }
|
|
93
|
+
attr_reader :dep
|
|
94
|
+
|
|
95
|
+
sig do
|
|
96
|
+
params(
|
|
97
|
+
content: String,
|
|
98
|
+
source_req: String,
|
|
99
|
+
new_r: T::Hash[Symbol, T.untyped],
|
|
100
|
+
old_r: T::Hash[Symbol, T.untyped]
|
|
101
|
+
).returns(T.nilable(String))
|
|
102
|
+
end
|
|
103
|
+
def replace_with_source_requirement(content, source_req, new_r, old_r)
|
|
104
|
+
match = content.match(declaration_regex(source_req))
|
|
105
|
+
return unless match
|
|
106
|
+
|
|
107
|
+
declaration = T.must(match[:declaration])
|
|
108
|
+
new_req_str = rewrite_pep508_requirement(source_req, old_r[:requirement], new_r[:requirement])
|
|
109
|
+
content.sub(declaration, declaration.sub(source_req, new_req_str))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Fallback when source_requirement metadata is absent (e.g. after
|
|
113
|
+
# DependencySet merge or deserialization). Matches using the
|
|
114
|
+
# normalized requirement string, which may fail on whitespace
|
|
115
|
+
# differences but is better than skipping the update entirely.
|
|
116
|
+
sig do
|
|
117
|
+
params(
|
|
118
|
+
content: String,
|
|
119
|
+
new_r: T::Hash[Symbol, T.untyped],
|
|
120
|
+
old_r: T::Hash[Symbol, T.untyped]
|
|
121
|
+
).returns(T.nilable(String))
|
|
122
|
+
end
|
|
123
|
+
def replace_with_normalized_requirement(content, new_r, old_r)
|
|
124
|
+
old_req = old_r[:requirement]
|
|
125
|
+
new_req = new_r[:requirement]
|
|
126
|
+
|
|
127
|
+
match = content.match(normalized_declaration_regex(old_req))
|
|
128
|
+
return unless match
|
|
129
|
+
|
|
130
|
+
declaration = T.must(match[:declaration])
|
|
131
|
+
return unless declaration.include?(old_req)
|
|
132
|
+
|
|
133
|
+
content.sub(declaration, declaration.sub(old_req, new_req))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
sig { params(old_req: String).returns(Regexp) }
|
|
137
|
+
def declaration_regex(old_req)
|
|
138
|
+
/(?<declaration>["']#{escape}\s*#{extras_pattern}\s*#{Regexp.escape(old_req)}["'])/mi
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
sig { params(old_req: String).returns(Regexp) }
|
|
142
|
+
def normalized_declaration_regex(old_req)
|
|
143
|
+
/(?<declaration>["']#{escape}#{extras_pattern}#{Regexp.escape(old_req)}["'])/mi
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sig { returns(String) }
|
|
147
|
+
def extras_pattern
|
|
148
|
+
extras_str = dep.metadata[:extras]
|
|
149
|
+
return "" unless extras_str.is_a?(String) && !extras_str.empty?
|
|
150
|
+
|
|
151
|
+
"\\[" + extras_str.split(",").map { |e| Regexp.escape(e.strip) }.join(",\\s*") + "\\]"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sig { returns(String) }
|
|
155
|
+
def escape
|
|
156
|
+
Regexp.escape(dep.name).gsub("\\-", "[-_.]")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -13,12 +13,14 @@ require "dependabot/python/file_parser/python_requirement_parser"
|
|
|
13
13
|
require "dependabot/python/file_updater"
|
|
14
14
|
require "dependabot/python/native_helpers"
|
|
15
15
|
require "dependabot/python/name_normaliser"
|
|
16
|
+
require "dependabot/python/poetry_plugin_installer"
|
|
16
17
|
|
|
17
18
|
module Dependabot
|
|
18
19
|
module Python
|
|
19
20
|
class FileUpdater
|
|
20
21
|
class PoetryFileUpdater
|
|
21
22
|
require_relative "pyproject_preparer"
|
|
23
|
+
require_relative "poetry_file_updater/pep621_updater"
|
|
22
24
|
extend T::Sig
|
|
23
25
|
|
|
24
26
|
sig { returns(T::Array[Dependabot::DependencyFile]) }
|
|
@@ -49,6 +51,7 @@ module Dependabot
|
|
|
49
51
|
@language_version_manager = T.let(nil, T.nilable(LanguageVersionManager))
|
|
50
52
|
@python_requirement_parser = T.let(nil, T.nilable(FileParser::PythonRequirementParser))
|
|
51
53
|
@updated_pyproject_content = T.let(nil, T.nilable(String))
|
|
54
|
+
@poetry_plugin_installer = T.let(nil, T.nilable(PoetryPluginInstaller))
|
|
52
55
|
@poetry_lock = T.let(nil, T.nilable(Dependabot::DependencyFile))
|
|
53
56
|
end
|
|
54
57
|
|
|
@@ -77,9 +80,7 @@ module Dependabot
|
|
|
77
80
|
)
|
|
78
81
|
end
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if lockfile
|
|
83
|
+
if lockfile && lockfile&.content != updated_lockfile_content
|
|
83
84
|
updated_files <<
|
|
84
85
|
updated_file(file: T.must(lockfile), content: updated_lockfile_content)
|
|
85
86
|
end
|
|
@@ -105,52 +106,51 @@ module Dependabot
|
|
|
105
106
|
updated_content
|
|
106
107
|
end
|
|
107
108
|
|
|
108
|
-
sig
|
|
109
|
-
params(
|
|
110
|
-
dep: Dependabot::Dependency,
|
|
111
|
-
content: String,
|
|
112
|
-
new_r: T::Hash[Symbol, T.untyped],
|
|
113
|
-
old_r: T::Hash[Symbol, T.untyped]
|
|
114
|
-
).returns(String)
|
|
115
|
-
end
|
|
109
|
+
sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(String) }
|
|
116
110
|
def replace_dep(dep, content, new_r, old_r)
|
|
117
|
-
# Handle Git dependencies with tags
|
|
118
111
|
return update_git_tag(dep, content, new_r, old_r) if git_dependency?(new_r) && git_dependency?(old_r)
|
|
119
112
|
|
|
113
|
+
replace_poetry_dep(dep, content, new_r, old_r) ||
|
|
114
|
+
replace_poetry_table_dep(dep, content, new_r, old_r) ||
|
|
115
|
+
replace_pep621_dep(dep, content, new_r, old_r) ||
|
|
116
|
+
content
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
|
|
120
|
+
def replace_poetry_dep(dep, content, new_r, old_r)
|
|
120
121
|
new_req = new_r[:requirement]
|
|
121
122
|
old_req = old_r[:requirement]
|
|
122
123
|
|
|
123
124
|
declaration_regex = declaration_regex(dep, old_r)
|
|
124
125
|
declaration_match = content.match(declaration_regex)
|
|
125
|
-
|
|
126
|
-
declaration = declaration_match[:declaration]
|
|
127
|
-
if T.must(declaration).include?(old_req)
|
|
128
|
-
new_declaration = T.must(declaration).sub(old_req, new_req)
|
|
129
|
-
return content.sub(T.must(declaration), new_declaration)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
126
|
+
return unless declaration_match
|
|
132
127
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
128
|
+
declaration = declaration_match[:declaration]
|
|
129
|
+
return unless T.must(declaration).include?(old_req)
|
|
130
|
+
|
|
131
|
+
new_declaration = T.must(declaration).sub(old_req, new_req)
|
|
132
|
+
content.sub(T.must(declaration), new_declaration)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
|
|
136
|
+
def replace_poetry_table_dep(dep, content, new_r, old_r)
|
|
137
|
+
old_req = old_r[:requirement]
|
|
138
|
+
new_req = new_r[:requirement]
|
|
139
|
+
regex = table_declaration_regex(dep, new_r)
|
|
140
|
+
|
|
141
|
+
return unless content.match(regex)
|
|
143
142
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
new_declaration = T.must(declaration).sub(old_req, new_req)
|
|
150
|
-
return content.sub(T.must(declaration), new_declaration)
|
|
143
|
+
content.gsub(regex) do |match|
|
|
144
|
+
match.gsub(
|
|
145
|
+
/(\s*version\s*=\s*["'])#{Regexp.escape(old_req)}/,
|
|
146
|
+
'\1' + new_req
|
|
147
|
+
)
|
|
151
148
|
end
|
|
149
|
+
end
|
|
152
150
|
|
|
153
|
-
|
|
151
|
+
sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
|
|
152
|
+
def replace_pep621_dep(dep, content, new_r, old_r)
|
|
153
|
+
Pep621Updater.new(dep: dep).replace(content, new_r, old_r)
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
|
@@ -158,14 +158,7 @@ module Dependabot
|
|
|
158
158
|
req.dig(:source, :type) == "git"
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
sig
|
|
162
|
-
params(
|
|
163
|
-
dep: Dependabot::Dependency,
|
|
164
|
-
content: String,
|
|
165
|
-
new_r: T::Hash[Symbol, T.untyped],
|
|
166
|
-
old_r: T::Hash[Symbol, T.untyped]
|
|
167
|
-
).returns(String)
|
|
168
|
-
end
|
|
161
|
+
sig { params(dep: Dependabot::Dependency, content: String, new_r: T::Hash[Symbol, T.untyped], old_r: T::Hash[Symbol, T.untyped]).returns(String) }
|
|
169
162
|
def update_git_tag(dep, content, new_r, old_r)
|
|
170
163
|
old_tag = old_r.dig(:source, :ref)
|
|
171
164
|
new_tag = new_r.dig(:source, :ref)
|
|
@@ -302,6 +295,7 @@ module Dependabot
|
|
|
302
295
|
add_auth_env_vars
|
|
303
296
|
|
|
304
297
|
language_version_manager.install_required_python
|
|
298
|
+
poetry_plugin_installer.install_required_plugins
|
|
305
299
|
|
|
306
300
|
# use system git instead of the pure Python dulwich
|
|
307
301
|
run_poetry_command("pyenv exec poetry config system-git-client true")
|
|
@@ -350,17 +344,7 @@ module Dependabot
|
|
|
350
344
|
.add_auth_env_vars(credentials)
|
|
351
345
|
end
|
|
352
346
|
|
|
353
|
-
sig
|
|
354
|
-
params(
|
|
355
|
-
pyproject_content: String
|
|
356
|
-
).returns(T.nilable(
|
|
357
|
-
T.any(
|
|
358
|
-
T::Hash[String, T.untyped],
|
|
359
|
-
String,
|
|
360
|
-
T::Array[T::Hash[String, T.untyped]]
|
|
361
|
-
)
|
|
362
|
-
))
|
|
363
|
-
end
|
|
347
|
+
sig { params(pyproject_content: String).returns(T.nilable(T.any(T::Hash[String, T.untyped], String, T::Array[T::Hash[String, T.untyped]]))) }
|
|
364
348
|
def pyproject_hash_for(pyproject_content)
|
|
365
349
|
SharedHelpers.in_a_temporary_directory do |dir|
|
|
366
350
|
SharedHelpers.with_git_configured(credentials: credentials) do
|
|
@@ -388,20 +372,6 @@ module Dependabot
|
|
|
388
372
|
/tool\.poetry\.#{old_req[:groups].first}\.#{escape(dep)}\](?:\r?\n).*?\s*version\s* =.*?(?:\r?\n)/m
|
|
389
373
|
end
|
|
390
374
|
|
|
391
|
-
sig { params(dep: Dependabot::Dependency, old_req: String).returns(Regexp) }
|
|
392
|
-
def pep621_declaration_regex(dep, old_req)
|
|
393
|
-
/(?<declaration>["']#{escape(dep)}#{extras_pattern(dep)}#{Regexp.escape(old_req)}["'])/mi
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
# Reconstructs extras from metadata for PEP 621 regex matching.
|
|
397
|
-
sig { params(dep: Dependabot::Dependency).returns(String) }
|
|
398
|
-
def extras_pattern(dep)
|
|
399
|
-
extras_str = dep.metadata[:extras]
|
|
400
|
-
return "" unless extras_str.is_a?(String) && !extras_str.empty?
|
|
401
|
-
|
|
402
|
-
"\\[" + extras_str.split(",").map { |e| Regexp.escape(e.strip) }.join(",\\s*") + "\\]"
|
|
403
|
-
end
|
|
404
|
-
|
|
405
375
|
sig { params(dep: Dependency).returns(String) }
|
|
406
376
|
def escape(dep)
|
|
407
377
|
Regexp.escape(dep.name).gsub("\\-", "[-_.]")
|
|
@@ -448,6 +418,14 @@ module Dependabot
|
|
|
448
418
|
)
|
|
449
419
|
end
|
|
450
420
|
|
|
421
|
+
sig { returns(PoetryPluginInstaller) }
|
|
422
|
+
def poetry_plugin_installer
|
|
423
|
+
@poetry_plugin_installer ||= T.let(
|
|
424
|
+
PoetryPluginInstaller.from_dependency_files(dependency_files),
|
|
425
|
+
T.nilable(PoetryPluginInstaller)
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
|
|
451
429
|
sig { returns(T.nilable(Dependabot::DependencyFile)) }
|
|
452
430
|
def pyproject
|
|
453
431
|
@pyproject ||=
|
|
@@ -16,29 +16,28 @@ module Dependabot
|
|
|
16
16
|
class PyprojectPreparer
|
|
17
17
|
extend T::Sig
|
|
18
18
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Regexp
|
|
24
|
-
|
|
25
|
-
private_class_method :pep508_name_pattern
|
|
19
|
+
# Fixed regex for extracting the name+extras prefix from a PEP 508 entry.
|
|
20
|
+
# Does not interpolate library input, avoiding polynomial backtracking.
|
|
21
|
+
PEP508_PREFIX = T.let(
|
|
22
|
+
/\A(?<prefix>(?<name>[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)(?:\[[^\]]*\])?)/i,
|
|
23
|
+
Regexp
|
|
24
|
+
)
|
|
26
25
|
|
|
27
26
|
# Pins a single PEP 508 dependency entry string to a specific version,
|
|
28
27
|
# preserving extras and environment markers.
|
|
29
|
-
sig { params(entry: String,
|
|
30
|
-
def self.pin_pep508_entry(entry,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
sig { params(entry: String, version: String).returns(String) }
|
|
29
|
+
def self.pin_pep508_entry(entry, version)
|
|
30
|
+
prefix_match = entry.match(PEP508_PREFIX)
|
|
31
|
+
return entry unless prefix_match
|
|
32
|
+
|
|
33
|
+
prefix = T.must(prefix_match[:prefix])
|
|
34
|
+
rest = T.must(entry[prefix.length..])
|
|
35
|
+
|
|
36
|
+
# Extract the environment marker ("; ..." suffix) if present
|
|
37
|
+
marker_match = rest.match(/(\s*;.*)/)
|
|
38
|
+
marker = marker_match ? marker_match[1] : ""
|
|
39
|
+
|
|
40
|
+
"#{prefix}==#{version}#{marker}"
|
|
42
41
|
end
|
|
43
42
|
private_class_method :pin_pep508_entry
|
|
44
43
|
|
|
@@ -79,12 +78,14 @@ module Dependabot
|
|
|
79
78
|
|
|
80
79
|
sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
|
|
81
80
|
def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
|
|
82
|
-
|
|
81
|
+
normalised_name = NameNormaliser.normalise(dep.name)
|
|
83
82
|
dep_arrays.each do |arr|
|
|
84
83
|
arr.each_with_index do |entry, i|
|
|
85
|
-
|
|
84
|
+
match = entry.match(PEP508_PREFIX)
|
|
85
|
+
next unless match
|
|
86
|
+
next unless NameNormaliser.normalise(T.must(match[:name])) == normalised_name
|
|
86
87
|
|
|
87
|
-
arr[i] = pin_pep508_entry(entry,
|
|
88
|
+
arr[i] = pin_pep508_entry(entry, T.must(dep.version))
|
|
88
89
|
end
|
|
89
90
|
end
|
|
90
91
|
end
|
|
@@ -138,8 +139,8 @@ module Dependabot
|
|
|
138
139
|
.gsub('#{', "{")
|
|
139
140
|
end
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
UNSUPPORTED_SOURCE_TYPES = T.let(%w(directory file url).freeze, T::Array[String])
|
|
143
|
+
|
|
143
144
|
sig { params(dependencies: T::Array[Dependabot::Dependency]).returns(String) }
|
|
144
145
|
def freeze_top_level_dependencies_except(dependencies)
|
|
145
146
|
return pyproject_content unless lockfile
|
|
@@ -154,32 +155,10 @@ module Dependabot
|
|
|
154
155
|
Dependabot::Python::FileParser::PyprojectFilesParser::POETRY_DEPENDENCY_TYPES.each do |key|
|
|
155
156
|
next unless poetry_object[key]
|
|
156
157
|
|
|
157
|
-
source_types = %w(directory file url)
|
|
158
158
|
poetry_object.fetch(key).each do |dep_name, _|
|
|
159
159
|
next if excluded_names.include?(normalise(dep_name))
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
next unless (locked_version = locked_details&.fetch("version"))
|
|
164
|
-
|
|
165
|
-
next if source_types.include?(locked_details.dig("source", "type"))
|
|
166
|
-
|
|
167
|
-
if locked_details.dig("source", "type") == "git"
|
|
168
|
-
poetry_object[key][dep_name] = {
|
|
169
|
-
"git" => locked_details.dig("source", "url"),
|
|
170
|
-
"rev" => locked_details.dig("source", "reference")
|
|
171
|
-
}
|
|
172
|
-
subdirectory = locked_details.dig("source", "subdirectory")
|
|
173
|
-
poetry_object[key][dep_name]["subdirectory"] = subdirectory if subdirectory
|
|
174
|
-
elsif poetry_object[key][dep_name].is_a?(Hash)
|
|
175
|
-
poetry_object[key][dep_name]["version"] = locked_version
|
|
176
|
-
elsif poetry_object[key][dep_name].is_a?(Array)
|
|
177
|
-
# if it has multiple-constraints, locking to a single version is
|
|
178
|
-
# going to result in a bad lockfile, ignore
|
|
179
|
-
next
|
|
180
|
-
else
|
|
181
|
-
poetry_object[key][dep_name] = locked_version
|
|
182
|
-
end
|
|
161
|
+
freeze_poetry_dep!(poetry_object[key], dep_name)
|
|
183
162
|
end
|
|
184
163
|
end
|
|
185
164
|
|
|
@@ -188,8 +167,6 @@ module Dependabot
|
|
|
188
167
|
|
|
189
168
|
TomlRB.dump(pyproject_object)
|
|
190
169
|
end
|
|
191
|
-
# rubocop:enable Metrics/AbcSize
|
|
192
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
193
170
|
|
|
194
171
|
private
|
|
195
172
|
|
|
@@ -199,6 +176,33 @@ module Dependabot
|
|
|
199
176
|
sig { returns(T.nilable(Dependabot::DependencyFile)) }
|
|
200
177
|
attr_reader :lockfile
|
|
201
178
|
|
|
179
|
+
sig { params(deps_hash: T::Hash[String, T.untyped], dep_name: String).void }
|
|
180
|
+
def freeze_poetry_dep!(deps_hash, dep_name)
|
|
181
|
+
details = locked_details(dep_name)
|
|
182
|
+
return unless (locked_version = details&.fetch("version"))
|
|
183
|
+
|
|
184
|
+
source_type = details.dig("source", "type")
|
|
185
|
+
return if UNSUPPORTED_SOURCE_TYPES.include?(source_type)
|
|
186
|
+
|
|
187
|
+
if source_type == "git"
|
|
188
|
+
freeze_git_dep!(deps_hash, dep_name, details)
|
|
189
|
+
elsif deps_hash[dep_name].is_a?(Hash)
|
|
190
|
+
deps_hash[dep_name]["version"] = locked_version
|
|
191
|
+
elsif !deps_hash[dep_name].is_a?(Array)
|
|
192
|
+
deps_hash[dep_name] = locked_version
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
sig { params(deps_hash: T::Hash[String, T.untyped], dep_name: String, details: T::Hash[String, T.untyped]).void }
|
|
197
|
+
def freeze_git_dep!(deps_hash, dep_name, details)
|
|
198
|
+
deps_hash[dep_name] = {
|
|
199
|
+
"git" => details.dig("source", "url"),
|
|
200
|
+
"rev" => details.dig("source", "reference")
|
|
201
|
+
}
|
|
202
|
+
subdirectory = details.dig("source", "subdirectory")
|
|
203
|
+
deps_hash[dep_name]["subdirectory"] = subdirectory if subdirectory
|
|
204
|
+
end
|
|
205
|
+
|
|
202
206
|
sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
|
|
203
207
|
def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
|
|
204
208
|
project_object = pyproject_object["project"]
|
|
@@ -226,8 +230,7 @@ module Dependabot
|
|
|
226
230
|
locked_details = locked_details(dep_name)
|
|
227
231
|
next unless (locked_version = locked_details&.fetch("version"))
|
|
228
232
|
|
|
229
|
-
|
|
230
|
-
dep_array[index] = self.class.send(:pin_pep508_entry, entry, name_pattern, locked_version)
|
|
233
|
+
dep_array[index] = self.class.send(:pin_pep508_entry, entry, locked_version)
|
|
231
234
|
end
|
|
232
235
|
end
|
|
233
236
|
|
|
@@ -86,6 +86,22 @@ module Dependabot
|
|
|
86
86
|
def unsupported?
|
|
87
87
|
false
|
|
88
88
|
end
|
|
89
|
+
|
|
90
|
+
# Poetry supports requires-poetry constraints in pyproject.toml;
|
|
91
|
+
# other Python package managers don't have an equivalent mechanism.
|
|
92
|
+
sig { override.void }
|
|
93
|
+
def raise_if_unsupported!
|
|
94
|
+
super
|
|
95
|
+
return unless requirement
|
|
96
|
+
return unless version
|
|
97
|
+
return if T.cast(T.must(requirement).satisfied_by?(T.must(version)), T::Boolean)
|
|
98
|
+
|
|
99
|
+
raise Dependabot::ToolVersionNotSupported.new(
|
|
100
|
+
NAME,
|
|
101
|
+
version.to_s,
|
|
102
|
+
requirement.to_s
|
|
103
|
+
)
|
|
104
|
+
end
|
|
89
105
|
end
|
|
90
106
|
|
|
91
107
|
class PipCompilePackageManager < Dependabot::Ecosystem::VersionManager
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
require "toml-rb"
|
|
7
|
+
require "dependabot/dependency_file"
|
|
8
|
+
require "dependabot/errors"
|
|
9
|
+
require "dependabot/shared_helpers"
|
|
10
|
+
|
|
11
|
+
module Dependabot
|
|
12
|
+
module Python
|
|
13
|
+
class PoetryPluginInstaller
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
# Only allow valid PyPI package names to prevent command injection
|
|
17
|
+
VALID_PLUGIN_NAME = /\A[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?\z/
|
|
18
|
+
|
|
19
|
+
# Only allow valid version constraint characters to prevent command injection
|
|
20
|
+
VALID_CONSTRAINT = /\A[a-zA-Z0-9.*,!=<>~^ ]+\z/
|
|
21
|
+
|
|
22
|
+
sig { params(dependency_files: T::Array[Dependabot::DependencyFile]).returns(PoetryPluginInstaller) }
|
|
23
|
+
def self.from_dependency_files(dependency_files)
|
|
24
|
+
pyproject_content = dependency_files.find { |f| f.name == "pyproject.toml" }&.content
|
|
25
|
+
new(pyproject_content: pyproject_content)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(pyproject_content: T.nilable(String)).void }
|
|
29
|
+
def initialize(pyproject_content:)
|
|
30
|
+
@pyproject_content = T.let(pyproject_content, T.nilable(String))
|
|
31
|
+
@plugins_installed = T.let(false, T::Boolean)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { void }
|
|
35
|
+
def install_required_plugins
|
|
36
|
+
return if @plugins_installed
|
|
37
|
+
|
|
38
|
+
required_plugins.each do |name, constraint|
|
|
39
|
+
install_plugin(name, constraint)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@plugins_installed = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
sig { returns(T.nilable(String)) }
|
|
48
|
+
attr_reader :pyproject_content
|
|
49
|
+
|
|
50
|
+
sig { returns(T::Hash[String, String]) }
|
|
51
|
+
def required_plugins
|
|
52
|
+
return {} unless pyproject_content
|
|
53
|
+
|
|
54
|
+
parsed = TomlRB.parse(pyproject_content)
|
|
55
|
+
plugins = parsed.dig("tool", "poetry", "requires-plugins")
|
|
56
|
+
return {} unless plugins.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
plugins.each_with_object({}) do |(name, constraint), result|
|
|
59
|
+
next unless name.is_a?(String) && constraint.is_a?(String)
|
|
60
|
+
next unless valid_plugin_name?(name)
|
|
61
|
+
next unless valid_constraint?(constraint)
|
|
62
|
+
|
|
63
|
+
result[name] = constraint
|
|
64
|
+
end
|
|
65
|
+
rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
|
|
66
|
+
{}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { params(name: String).returns(T::Boolean) }
|
|
70
|
+
def valid_plugin_name?(name)
|
|
71
|
+
VALID_PLUGIN_NAME.match?(name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { params(constraint: String).returns(T::Boolean) }
|
|
75
|
+
def valid_constraint?(constraint)
|
|
76
|
+
VALID_CONSTRAINT.match?(constraint)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { params(name: String, constraint: String).void }
|
|
80
|
+
def install_plugin(name, constraint)
|
|
81
|
+
Dependabot.logger.info("Installing Poetry plugin: #{name}@#{constraint}")
|
|
82
|
+
|
|
83
|
+
escaped = Shellwords.shellescape("#{name}@#{constraint}")
|
|
84
|
+
SharedHelpers.run_shell_command(
|
|
85
|
+
"pyenv exec poetry self add #{escaped}",
|
|
86
|
+
fingerprint: "pyenv exec poetry self add <plugin_name>@<constraint>"
|
|
87
|
+
)
|
|
88
|
+
rescue SharedHelpers::HelperSubprocessFailed => e
|
|
89
|
+
Dependabot.logger.warn(
|
|
90
|
+
"Failed to install Poetry plugin #{name}@#{constraint}: #{e.message}"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -18,6 +18,7 @@ require "dependabot/python/requirement"
|
|
|
18
18
|
require "dependabot/python/native_helpers"
|
|
19
19
|
require "dependabot/python/authed_url_builder"
|
|
20
20
|
require "dependabot/python/name_normaliser"
|
|
21
|
+
require "dependabot/python/poetry_plugin_installer"
|
|
21
22
|
|
|
22
23
|
module Dependabot
|
|
23
24
|
module Python
|
|
@@ -90,6 +91,7 @@ module Dependabot
|
|
|
90
91
|
@original_reqs_resolvable = T.let(nil, T.nilable(T::Boolean))
|
|
91
92
|
@python_requirement_parser = T.let(nil, T.nilable(FileParser::PythonRequirementParser))
|
|
92
93
|
@language_version_manager = T.let(nil, T.nilable(LanguageVersionManager))
|
|
94
|
+
@poetry_plugin_installer = T.let(nil, T.nilable(PoetryPluginInstaller))
|
|
93
95
|
end
|
|
94
96
|
|
|
95
97
|
sig { params(requirement: T.nilable(String)).returns(T.nilable(Dependabot::Python::Version)) }
|
|
@@ -129,6 +131,9 @@ module Dependabot
|
|
|
129
131
|
|
|
130
132
|
language_version_manager.install_required_python
|
|
131
133
|
|
|
134
|
+
# Install any required Poetry plugins declared in pyproject.toml
|
|
135
|
+
poetry_plugin_installer.install_required_plugins
|
|
136
|
+
|
|
132
137
|
# use system git instead of the pure Python dulwich
|
|
133
138
|
run_poetry_command("pyenv exec poetry config system-git-client true")
|
|
134
139
|
|
|
@@ -385,6 +390,14 @@ module Dependabot
|
|
|
385
390
|
poetry_lock
|
|
386
391
|
end
|
|
387
392
|
|
|
393
|
+
sig { returns(PoetryPluginInstaller) }
|
|
394
|
+
def poetry_plugin_installer
|
|
395
|
+
@poetry_plugin_installer ||= T.let(
|
|
396
|
+
PoetryPluginInstaller.from_dependency_files(dependency_files),
|
|
397
|
+
T.nilable(PoetryPluginInstaller)
|
|
398
|
+
)
|
|
399
|
+
end
|
|
400
|
+
|
|
388
401
|
sig { params(command: String, fingerprint: T.nilable(String)).returns(String) }
|
|
389
402
|
def run_poetry_command(command, fingerprint: nil)
|
|
390
403
|
SharedHelpers.run_shell_command(command, fingerprint: fingerprint)
|
|
@@ -11,11 +11,13 @@ require "dependabot/requirements_update_strategy"
|
|
|
11
11
|
module Dependabot
|
|
12
12
|
module Python
|
|
13
13
|
class UpdateChecker
|
|
14
|
+
# rubocop:disable Metrics/ClassLength
|
|
14
15
|
class RequirementsUpdater
|
|
15
16
|
extend T::Sig
|
|
16
17
|
|
|
17
18
|
PYPROJECT_OR_SEPARATOR = T.let(/(?<=[a-zA-Z0-9*])\s*\|+/, Regexp)
|
|
18
19
|
PYPROJECT_SEPARATOR = T.let(/#{PYPROJECT_OR_SEPARATOR}|,/, Regexp)
|
|
20
|
+
LOWER_BOUND_OPS = T.let(%w(> >=).freeze, T::Array[String])
|
|
19
21
|
|
|
20
22
|
class UnfixableRequirement < StandardError; end
|
|
21
23
|
|
|
@@ -111,13 +113,25 @@ module Dependabot
|
|
|
111
113
|
def updated_pyproject_requirement(req)
|
|
112
114
|
return req unless latest_resolvable_version
|
|
113
115
|
return req unless req.fetch(:requirement)
|
|
114
|
-
return req if
|
|
116
|
+
return req if skip_pyproject_update?(req)
|
|
115
117
|
|
|
118
|
+
pyproject_update_for_strategy(req)
|
|
119
|
+
rescue UnfixableRequirement
|
|
120
|
+
req.merge(requirement: :unfixable)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
|
124
|
+
def skip_pyproject_update?(req)
|
|
125
|
+
new_version_satisfies?(req) && !has_lockfile &&
|
|
126
|
+
update_strategy != RequirementsUpdateStrategy::BumpVersions
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
130
|
+
def pyproject_update_for_strategy(req)
|
|
116
131
|
# If the requirement uses || syntax then we always want to widen it
|
|
117
132
|
return widen_pyproject_requirement(req) if req.fetch(:requirement).match?(PYPROJECT_OR_SEPARATOR)
|
|
118
133
|
|
|
119
|
-
# If the requirement is a development dependency we always want to
|
|
120
|
-
# bump it
|
|
134
|
+
# If the requirement is a development dependency we always want to bump it
|
|
121
135
|
return update_pyproject_version(req) if req.fetch(:groups).include?("dev-dependencies")
|
|
122
136
|
|
|
123
137
|
case update_strategy
|
|
@@ -126,37 +140,40 @@ module Dependabot
|
|
|
126
140
|
when RequirementsUpdateStrategy::BumpVersionsIfNecessary then update_pyproject_version_if_needed(req)
|
|
127
141
|
else raise "Unexpected update strategy: #{update_strategy}"
|
|
128
142
|
end
|
|
129
|
-
rescue UnfixableRequirement
|
|
130
|
-
req.merge(requirement: :unfixable)
|
|
131
143
|
end
|
|
132
144
|
|
|
133
145
|
sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
134
146
|
def update_pyproject_version_if_needed(req)
|
|
135
147
|
return req if new_version_satisfies?(req)
|
|
136
148
|
|
|
137
|
-
|
|
149
|
+
update_pyproject_version_core(req, bump_lower_bound: false)
|
|
138
150
|
end
|
|
139
151
|
|
|
140
152
|
sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
141
153
|
def update_pyproject_version(req)
|
|
154
|
+
return req if req[:requirement] == "*"
|
|
155
|
+
|
|
156
|
+
update_pyproject_version_core(req, bump_lower_bound: true)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
sig do
|
|
160
|
+
params(
|
|
161
|
+
req: T::Hash[Symbol, T.untyped],
|
|
162
|
+
bump_lower_bound: T::Boolean
|
|
163
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
164
|
+
end
|
|
165
|
+
def update_pyproject_version_core(req, bump_lower_bound:)
|
|
142
166
|
requirement_strings = req[:requirement].split(",").map(&:strip)
|
|
143
167
|
|
|
144
168
|
new_requirement =
|
|
145
169
|
if requirement_strings.any? { |r| r.match?(/^=|^\d/) }
|
|
146
|
-
# If there is an equality operator, just update that. It must
|
|
147
|
-
# be binding and any other requirements will be being ignored
|
|
148
170
|
find_and_update_equality_match(requirement_strings)
|
|
149
171
|
elsif requirement_strings.any? { |r| r.start_with?("~", "^") }
|
|
150
|
-
# If a compatibility operator is being used, just bump its
|
|
151
|
-
# version (and remove any other requirements)
|
|
152
172
|
v_req = requirement_strings.find { |r| r.start_with?("~", "^") }
|
|
153
173
|
bump_version(v_req, latest_resolvable_version.to_s)
|
|
154
|
-
elsif
|
|
155
|
-
|
|
156
|
-
# required if it's already satisfied
|
|
157
|
-
req.fetch(:requirement)
|
|
174
|
+
elsif bump_lower_bound
|
|
175
|
+
bump_requirements_range(requirement_strings)
|
|
158
176
|
else
|
|
159
|
-
# But if it's not, update it
|
|
160
177
|
update_requirements_range(requirement_strings)
|
|
161
178
|
end
|
|
162
179
|
|
|
@@ -344,6 +361,59 @@ module Dependabot
|
|
|
344
361
|
.sort_by { |r| requirement_class.new(r).requirements.first.last }.join(",").delete(" ")
|
|
345
362
|
end
|
|
346
363
|
|
|
364
|
+
# Bumps the lower bound of a range requirement to the latest version
|
|
365
|
+
# Used by BumpVersions strategy to increase the minimum version
|
|
366
|
+
sig { params(requirement_strings: T::Array[String]).returns(String) }
|
|
367
|
+
def bump_requirements_range(requirement_strings)
|
|
368
|
+
ruby_requirements = requirement_strings.map { |r| requirement_class.new(r) }
|
|
369
|
+
|
|
370
|
+
validate_lower_bounds_not_too_high(ruby_requirements)
|
|
371
|
+
|
|
372
|
+
updated_requirement_strings = ruby_requirements.map { |r| bump_single_requirement(r) }
|
|
373
|
+
|
|
374
|
+
updated_requirement_strings
|
|
375
|
+
.sort_by { |r| requirement_class.new(r).requirements.first.last }.join(",").delete(" ")
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
sig { params(ruby_requirements: T::Array[Dependabot::Python::Requirement]).void }
|
|
379
|
+
def validate_lower_bounds_not_too_high(ruby_requirements)
|
|
380
|
+
ruby_requirements.each do |r|
|
|
381
|
+
op, version = r.requirements.first
|
|
382
|
+
raise UnfixableRequirement if LOWER_BOUND_OPS.include?(op) && version > T.must(latest_resolvable_version)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
sig { params(req: Dependabot::Python::Requirement).returns(String) }
|
|
387
|
+
def bump_single_requirement(req)
|
|
388
|
+
op, version = req.requirements.first
|
|
389
|
+
|
|
390
|
+
case op
|
|
391
|
+
when ">=" then ">=" + T.must(latest_resolvable_version).to_s
|
|
392
|
+
# Strict lower bound becomes inclusive because the resolved version
|
|
393
|
+
# is the exact target — using ">" would exclude it.
|
|
394
|
+
when ">" then ">=" + T.must(latest_resolvable_version).to_s
|
|
395
|
+
when "<" then bump_upper_bound_less_than(req, version)
|
|
396
|
+
when "<=" then bump_upper_bound_less_or_equal(req)
|
|
397
|
+
when "~>", "~=" then bump_version(req.to_s, T.must(latest_resolvable_version).to_s)
|
|
398
|
+
when "!=" then req.to_s
|
|
399
|
+
else req.to_s
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
sig { params(req: Dependabot::Python::Requirement, version: Gem::Version).returns(String) }
|
|
404
|
+
def bump_upper_bound_less_than(req, version)
|
|
405
|
+
return req.to_s if req.satisfied_by?(T.must(latest_resolvable_version))
|
|
406
|
+
|
|
407
|
+
"<" + update_greatest_version(version, T.must(latest_resolvable_version))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
sig { params(req: Dependabot::Python::Requirement).returns(String) }
|
|
411
|
+
def bump_upper_bound_less_or_equal(req)
|
|
412
|
+
return req.to_s if req.satisfied_by?(T.must(latest_resolvable_version))
|
|
413
|
+
|
|
414
|
+
"<=" + T.must(latest_resolvable_version).to_s
|
|
415
|
+
end
|
|
416
|
+
|
|
347
417
|
# Updates the version in a constraint to be the given version
|
|
348
418
|
sig { params(req_string: String, version_to_be_permitted: String).returns(String) }
|
|
349
419
|
def bump_version(req_string, version_to_be_permitted)
|
|
@@ -448,6 +518,7 @@ module Dependabot
|
|
|
448
518
|
Python::Requirement
|
|
449
519
|
end
|
|
450
520
|
end
|
|
521
|
+
# rubocop:enable Metrics/ClassLength
|
|
451
522
|
end
|
|
452
523
|
end
|
|
453
524
|
end
|
|
@@ -409,14 +409,18 @@ module Dependabot
|
|
|
409
409
|
def check_pypi_for_library_match
|
|
410
410
|
return false unless updating_pyproject? && library_details && !T.must(library_details)["name"].nil?
|
|
411
411
|
|
|
412
|
+
# If the project has a description in its pyproject.toml metadata, treat it as a
|
|
413
|
+
# library when PyPI is unavailable or the package isn't published there yet.
|
|
414
|
+
has_library_metadata = !T.must(library_details)["description"].nil?
|
|
415
|
+
|
|
412
416
|
response = Dependabot::RegistryClient.get(
|
|
413
417
|
url: "https://pypi.org/pypi/#{normalised_name(T.must(library_details)['name'])}/json/"
|
|
414
418
|
)
|
|
415
|
-
return
|
|
419
|
+
return has_library_metadata unless response.status == 200
|
|
416
420
|
|
|
417
421
|
(JSON.parse(response.body)["info"] || {})["summary"] == T.must(library_details)["description"]
|
|
418
422
|
rescue Excon::Error::Timeout, Excon::Error::Socket, URI::InvalidURIError
|
|
419
|
-
|
|
423
|
+
has_library_metadata
|
|
420
424
|
end
|
|
421
425
|
|
|
422
426
|
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.370.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.370.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.370.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: debug
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -256,6 +256,7 @@ files:
|
|
|
256
256
|
- helpers/test/fixtures/pep621_multiple_extras.toml
|
|
257
257
|
- helpers/test/fixtures/pep621_no_version.toml
|
|
258
258
|
- helpers/test/fixtures/pep621_only_build_system.toml
|
|
259
|
+
- helpers/test/fixtures/pep621_spaced_specifiers.toml
|
|
259
260
|
- helpers/test/fixtures/pep735_cycle.toml
|
|
260
261
|
- helpers/test/fixtures/pep735_dependency_groups.toml
|
|
261
262
|
- helpers/test/fixtures/requirements/constraints.txt
|
|
@@ -288,6 +289,7 @@ files:
|
|
|
288
289
|
- lib/dependabot/python/file_updater/pipfile_manifest_updater.rb
|
|
289
290
|
- lib/dependabot/python/file_updater/pipfile_preparer.rb
|
|
290
291
|
- lib/dependabot/python/file_updater/poetry_file_updater.rb
|
|
292
|
+
- lib/dependabot/python/file_updater/poetry_file_updater/pep621_updater.rb
|
|
291
293
|
- lib/dependabot/python/file_updater/pyproject_preparer.rb
|
|
292
294
|
- lib/dependabot/python/file_updater/requirement_file_updater.rb
|
|
293
295
|
- lib/dependabot/python/file_updater/requirement_replacer.rb
|
|
@@ -302,6 +304,7 @@ files:
|
|
|
302
304
|
- lib/dependabot/python/package_manager.rb
|
|
303
305
|
- lib/dependabot/python/pip_compile_file_matcher.rb
|
|
304
306
|
- lib/dependabot/python/pipenv_runner.rb
|
|
307
|
+
- lib/dependabot/python/poetry_plugin_installer.rb
|
|
305
308
|
- lib/dependabot/python/requirement.rb
|
|
306
309
|
- lib/dependabot/python/requirement_parser.rb
|
|
307
310
|
- lib/dependabot/python/shared_file_fetcher.rb
|
|
@@ -319,7 +322,7 @@ licenses:
|
|
|
319
322
|
- MIT
|
|
320
323
|
metadata:
|
|
321
324
|
bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
|
|
322
|
-
changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.
|
|
325
|
+
changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.370.0
|
|
323
326
|
rdoc_options: []
|
|
324
327
|
require_paths:
|
|
325
328
|
- lib
|