dependabot-python 0.368.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/build +4 -0
  3. data/helpers/lib/parser.py +26 -0
  4. data/helpers/requirements.txt +1 -0
  5. data/helpers/test/fixtures/no_dependencies.toml +3 -0
  6. data/helpers/test/fixtures/pep621_arbitrary_equality.toml +7 -0
  7. data/helpers/test/fixtures/pep621_dependencies.toml +21 -0
  8. data/helpers/test/fixtures/pep621_empty_deps.toml +8 -0
  9. data/helpers/test/fixtures/pep621_extras.toml +8 -0
  10. data/helpers/test/fixtures/pep621_markers.toml +7 -0
  11. data/helpers/test/fixtures/pep621_multiple_extras.toml +7 -0
  12. data/helpers/test/fixtures/pep621_no_version.toml +8 -0
  13. data/helpers/test/fixtures/pep621_only_build_system.toml +3 -0
  14. data/helpers/test/fixtures/pep621_spaced_specifiers.toml +10 -0
  15. data/helpers/test/fixtures/pep735_cycle.toml +13 -0
  16. data/helpers/test/fixtures/pep735_dependency_groups.toml +18 -0
  17. data/helpers/test/fixtures/requirements/constraints.txt +1 -0
  18. data/helpers/test/fixtures/requirements/markers.txt +1 -0
  19. data/helpers/test/fixtures/requirements/requirements-dev.txt +2 -0
  20. data/helpers/test/fixtures/requirements/requirements.txt +5 -0
  21. data/helpers/test/fixtures/requirements/with_constraints.txt +2 -0
  22. data/helpers/test/fixtures/requirements_empty/.gitkeep +0 -0
  23. data/helpers/test/fixtures/setup_cfg/setup.cfg +16 -0
  24. data/helpers/test/fixtures/setup_py/setup.py +20 -0
  25. data/helpers/test/fixtures/setup_py_comments/setup.py +9 -0
  26. data/helpers/test/test_hasher.py +114 -0
  27. data/helpers/test/test_parse_requirements.py +103 -0
  28. data/helpers/test/test_parse_setup.py +127 -0
  29. data/helpers/test/test_parser.py +265 -0
  30. data/helpers/test/test_run.py +49 -0
  31. data/lib/dependabot/python/dependency_grapher/lockfile_generator.rb +13 -0
  32. data/lib/dependabot/python/file_parser/pyproject_files_parser.rb +42 -11
  33. data/lib/dependabot/python/file_parser.rb +21 -1
  34. data/lib/dependabot/python/file_updater/poetry_file_updater/pep621_updater.rb +162 -0
  35. data/lib/dependabot/python/file_updater/poetry_file_updater.rb +60 -77
  36. data/lib/dependabot/python/file_updater/pyproject_preparer.rb +139 -27
  37. data/lib/dependabot/python/package_manager.rb +16 -0
  38. data/lib/dependabot/python/poetry_plugin_installer.rb +95 -0
  39. data/lib/dependabot/python/update_checker/latest_version_finder.rb +4 -2
  40. data/lib/dependabot/python/update_checker/poetry_version_resolver.rb +13 -0
  41. data/lib/dependabot/python/update_checker/requirements_updater.rb +86 -15
  42. data/lib/dependabot/python/update_checker.rb +6 -2
  43. metadata +32 -4
@@ -0,0 +1,265 @@
1
+ import json
2
+ import os
3
+ import sys
4
+
5
+ # Add the helpers lib directory to the Python path so we can import parser
6
+ sys.path.insert(
7
+ 0, os.path.join(os.path.dirname(__file__), os.pardir, "lib")
8
+ )
9
+
10
+ from parser import parse_pep621_pep735_dependencies # noqa: E402
11
+
12
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
13
+
14
+
15
+ def parse(fixture_name):
16
+ path = os.path.join(FIXTURES, fixture_name)
17
+ result = json.loads(parse_pep621_pep735_dependencies(path))
18
+ return result["result"]
19
+
20
+
21
+ def find_dep(deps, name):
22
+ return next((d for d in deps if d["name"] == name), None)
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # PEP 621 project.dependencies
27
+ # ---------------------------------------------------------------------------
28
+ class TestPep621Dependencies:
29
+ def test_parses_runtime_dependencies(self):
30
+ deps = parse("pep621_dependencies.toml")
31
+ requests = find_dep(deps, "requests")
32
+ assert requests is not None
33
+ # packaging normalises specifier order alphabetically by operator
34
+ assert requests["requirement"] == "<3.0,>=2.13.0"
35
+ assert requests["requirement_type"] == "dependencies"
36
+
37
+ def test_parses_exact_version(self):
38
+ deps = parse("pep621_dependencies.toml")
39
+ urllib3 = find_dep(deps, "urllib3")
40
+ assert urllib3 is not None
41
+ assert urllib3["version"] == "1.26.0"
42
+ assert urllib3["requirement"] == "==1.26.0"
43
+
44
+ def test_parses_optional_dependencies(self):
45
+ deps = parse("pep621_dependencies.toml")
46
+ pysocks = find_dep(deps, "PySocks")
47
+ assert pysocks is not None
48
+ assert pysocks["requirement_type"] == "socks"
49
+
50
+ def test_optional_dependency_specifiers(self):
51
+ deps = parse("pep621_dependencies.toml")
52
+ pysocks = find_dep(deps, "PySocks")
53
+ # packaging normalises specifiers: sorted, no spaces
54
+ assert "!=" in pysocks["requirement"]
55
+ assert ">=" in pysocks["requirement"]
56
+
57
+ def test_parses_multiple_optional_groups(self):
58
+ deps = parse("pep621_dependencies.toml")
59
+ group_types = {d["requirement_type"] for d in deps}
60
+ assert "socks" in group_types
61
+ assert "tests" in group_types
62
+
63
+ def test_parses_build_system_requires(self):
64
+ deps = parse("pep621_dependencies.toml")
65
+ setuptools = find_dep(deps, "setuptools")
66
+ assert setuptools is not None
67
+ assert setuptools["requirement_type"] == "build-system.requires"
68
+ assert setuptools["requirement"] == ">=68.0"
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # PEP 621 extras
73
+ # ---------------------------------------------------------------------------
74
+ class TestPep621Extras:
75
+ def test_parses_extras(self):
76
+ deps = parse("pep621_extras.toml")
77
+ cc = find_dep(deps, "cachecontrol")
78
+ assert cc is not None
79
+ assert cc["extras"] == ["filecache"]
80
+
81
+ def test_extras_requirement(self):
82
+ deps = parse("pep621_extras.toml")
83
+ cc = find_dep(deps, "cachecontrol")
84
+ assert cc["requirement"] == ">=0.14.0"
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # PEP 735 dependency-groups
89
+ # ---------------------------------------------------------------------------
90
+ class TestPep735DependencyGroups:
91
+ def test_parses_dependency_group(self):
92
+ deps = parse("pep735_dependency_groups.toml")
93
+ pytest_dep = find_dep(deps, "pytest")
94
+ assert pytest_dep is not None
95
+ assert pytest_dep["requirement_type"] == "dev"
96
+ assert pytest_dep["version"] == "7.1.3"
97
+
98
+ def test_include_group_resolves(self):
99
+ deps = parse("pep735_dependency_groups.toml")
100
+ # "lint" group includes "dev" via include-group, plus flake8
101
+ lint_deps = [d for d in deps if d["requirement_type"] == "lint"]
102
+ lint_names = {d["name"] for d in lint_deps}
103
+ assert "flake8" in lint_names
104
+ # include-group pulls dev deps into lint but they keep their
105
+ # original group name, so they appear under "dev" requirement_type
106
+ all_names = {d["name"] for d in deps}
107
+ assert "pytest" in all_names
108
+ assert "black" in all_names
109
+
110
+ def test_include_group_lists_all_resolved_deps(self):
111
+ deps = parse("pep735_dependency_groups.toml")
112
+ # pytest appears twice: once from direct "dev" processing and
113
+ # once from "lint" including "dev" (both with requirement_type="dev"
114
+ # because included deps keep their original group name)
115
+ pytests = [d for d in deps if d["name"] == "pytest"]
116
+ assert len(pytests) == 2
117
+ assert all(d["requirement_type"] == "dev" for d in pytests)
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Markers
122
+ # ---------------------------------------------------------------------------
123
+ class TestMarkers:
124
+ def test_parses_markers(self):
125
+ deps = parse("pep621_markers.toml")
126
+ requests = find_dep(deps, "requests")
127
+ assert requests is not None
128
+ assert requests["markers"] == 'python_version >= "3.8"'
129
+
130
+ def test_requirement_with_markers(self):
131
+ deps = parse("pep621_markers.toml")
132
+ requests = find_dep(deps, "requests")
133
+ assert requests["requirement"] == ">=2.13.0"
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Edge cases
138
+ # ---------------------------------------------------------------------------
139
+ class TestEdgeCases:
140
+ def test_no_dependencies(self):
141
+ deps = parse("no_dependencies.toml")
142
+ assert deps == []
143
+
144
+ def test_only_build_system(self):
145
+ deps = parse("pep621_only_build_system.toml")
146
+ assert len(deps) == 2
147
+ names = {d["name"] for d in deps}
148
+ assert "setuptools" in names
149
+ assert "wheel" in names
150
+ assert all(
151
+ d["requirement_type"] == "build-system.requires" for d in deps
152
+ )
153
+
154
+ def test_empty_dependency_lists(self):
155
+ deps = parse("pep621_empty_deps.toml")
156
+ assert deps == []
157
+
158
+ def test_multiple_extras_on_single_dep(self):
159
+ deps = parse("pep621_multiple_extras.toml")
160
+ boto3 = find_dep(deps, "boto3")
161
+ assert boto3 is not None
162
+ assert boto3["extras"] == ["crt", "s3"]
163
+ assert boto3["requirement"] == ">=1.28.0"
164
+
165
+ def test_arbitrary_equality_operator(self):
166
+ deps = parse("pep621_arbitrary_equality.toml")
167
+ numpy = find_dep(deps, "numpy")
168
+ assert numpy is not None
169
+ assert numpy["version"] == "1.24.0rc1"
170
+ assert numpy["requirement"] == "===1.24.0rc1"
171
+
172
+ def test_no_version_specifier(self):
173
+ deps = parse("pep621_no_version.toml")
174
+ requests = find_dep(deps, "requests")
175
+ assert requests is not None
176
+ assert requests["version"] is None
177
+ assert requests["requirement"] == ""
178
+
179
+ def test_cyclic_include_group_does_not_loop(self):
180
+ deps = parse("pep735_cycle.toml")
181
+ names = [d["name"] for d in deps]
182
+ # Both groups should be processed without infinite recursion
183
+ assert "requests" in names
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"
@@ -0,0 +1,49 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+
6
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
7
+ HELPERS_DIR = os.path.join(os.path.dirname(__file__), os.pardir)
8
+ RUN_PY = os.path.join(HELPERS_DIR, "run.py")
9
+
10
+
11
+ def run_helper(function, args):
12
+ input_json = json.dumps({"function": function, "args": args})
13
+ result = subprocess.run(
14
+ [sys.executable, RUN_PY],
15
+ input=input_json,
16
+ capture_output=True,
17
+ text=True,
18
+ cwd=HELPERS_DIR,
19
+ )
20
+ assert result.returncode == 0, (
21
+ f"run.py failed: {result.stderr}"
22
+ )
23
+ return json.loads(result.stdout)
24
+
25
+
26
+ class TestRunRouting:
27
+ def test_parse_pep621_routing(self):
28
+ fixture = os.path.join(FIXTURES, "pep621_dependencies.toml")
29
+ result = run_helper("parse_pep621_pep735_dependencies", [fixture])
30
+
31
+ assert "result" in result
32
+ names = {d["name"] for d in result["result"]}
33
+ assert "requests" in names
34
+
35
+ def test_parse_setup_routing(self):
36
+ fixture_dir = os.path.join(FIXTURES, "setup_py")
37
+ result = run_helper("parse_setup", [fixture_dir])
38
+
39
+ assert "result" in result
40
+ names = {d["name"] for d in result["result"]}
41
+ assert "requests" in names
42
+
43
+ def test_parse_requirements_routing(self):
44
+ fixture_dir = os.path.join(FIXTURES, "requirements")
45
+ result = run_helper("parse_requirements", [fixture_dir])
46
+
47
+ assert "result" in result
48
+ names = {d["name"] for d in result["result"]}
49
+ assert "requests" in names
@@ -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
- # If a requirement has a `<` or `<=` marker then updating it is
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
- return PoetryPackageManager.new(T.must(detect_poetry_version)) if detect_poetry_version
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