dependabot-python 0.367.0 → 0.369.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/build +4 -0
  3. data/helpers/requirements.txt +1 -0
  4. data/helpers/test/fixtures/no_dependencies.toml +3 -0
  5. data/helpers/test/fixtures/pep621_arbitrary_equality.toml +7 -0
  6. data/helpers/test/fixtures/pep621_dependencies.toml +21 -0
  7. data/helpers/test/fixtures/pep621_empty_deps.toml +8 -0
  8. data/helpers/test/fixtures/pep621_extras.toml +8 -0
  9. data/helpers/test/fixtures/pep621_markers.toml +7 -0
  10. data/helpers/test/fixtures/pep621_multiple_extras.toml +7 -0
  11. data/helpers/test/fixtures/pep621_no_version.toml +8 -0
  12. data/helpers/test/fixtures/pep621_only_build_system.toml +3 -0
  13. data/helpers/test/fixtures/pep735_cycle.toml +13 -0
  14. data/helpers/test/fixtures/pep735_dependency_groups.toml +18 -0
  15. data/helpers/test/fixtures/requirements/constraints.txt +1 -0
  16. data/helpers/test/fixtures/requirements/markers.txt +1 -0
  17. data/helpers/test/fixtures/requirements/requirements-dev.txt +2 -0
  18. data/helpers/test/fixtures/requirements/requirements.txt +5 -0
  19. data/helpers/test/fixtures/requirements/with_constraints.txt +2 -0
  20. data/helpers/test/fixtures/requirements_empty/.gitkeep +0 -0
  21. data/helpers/test/fixtures/setup_cfg/setup.cfg +16 -0
  22. data/helpers/test/fixtures/setup_py/setup.py +20 -0
  23. data/helpers/test/fixtures/setup_py_comments/setup.py +9 -0
  24. data/helpers/test/test_hasher.py +114 -0
  25. data/helpers/test/test_parse_requirements.py +103 -0
  26. data/helpers/test/test_parse_setup.py +127 -0
  27. data/helpers/test/test_parser.py +184 -0
  28. data/helpers/test/test_run.py +49 -0
  29. data/lib/dependabot/python/file_updater/poetry_file_updater.rb +16 -11
  30. data/lib/dependabot/python/file_updater/pyproject_preparer.rb +109 -0
  31. data/lib/dependabot/python/file_updater/requirement_file_updater.rb +36 -13
  32. data/lib/dependabot/python/file_updater/requirement_replacer.rb +29 -21
  33. data/lib/dependabot/python/metadata_finder.rb +152 -20
  34. data/lib/dependabot/python/shared_file_fetcher.rb +10 -5
  35. data/lib/dependabot/python/update_checker/latest_version_finder.rb +4 -2
  36. metadata +29 -4
@@ -0,0 +1,184 @@
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
@@ -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
@@ -124,8 +124,10 @@ module Dependabot
124
124
  declaration_match = content.match(declaration_regex)
125
125
  if declaration_match
126
126
  declaration = declaration_match[:declaration]
127
- new_declaration = T.must(declaration).sub(old_req, new_req)
128
- return content.sub(T.must(declaration), new_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
129
131
  end
130
132
 
131
133
  # Try Poetry table format
@@ -220,9 +222,8 @@ module Dependabot
220
222
 
221
223
  sig { params(pyproject_content: String).returns(String) }
222
224
  def freeze_other_dependencies(pyproject_content)
223
- PyprojectPreparer
224
- .new(pyproject_content: pyproject_content, lockfile: lockfile)
225
- .freeze_top_level_dependencies_except(dependencies)
225
+ PyprojectPreparer.new(pyproject_content: pyproject_content, lockfile: lockfile)
226
+ .freeze_top_level_dependencies_except(dependencies)
226
227
  end
227
228
 
228
229
  sig { params(pyproject_content: String).returns(String) }
@@ -244,14 +245,18 @@ module Dependabot
244
245
  end
245
246
  end
246
247
 
248
+ # Freeze PEP 621 project.dependencies and project.optional-dependencies
249
+ PyprojectPreparer.freeze_pep621_deps!(pyproject_object, dependencies) do |dep|
250
+ !git_dependency_being_updated?(dep)
251
+ end
252
+
247
253
  TomlRB.dump(pyproject_object)
248
254
  end
249
255
 
250
256
  sig { params(pyproject_content: String).returns(String) }
251
257
  def update_python_requirement(pyproject_content)
252
- PyprojectPreparer
253
- .new(pyproject_content: pyproject_content)
254
- .update_python_requirement(language_version_manager.python_version)
258
+ PyprojectPreparer.new(pyproject_content: pyproject_content)
259
+ .update_python_requirement(language_version_manager.python_version)
255
260
  end
256
261
 
257
262
  sig { params(poetry_object: T::Hash[String, T.untyped], dep: Dependabot::Dependency).returns(T::Array[String]) }
@@ -262,6 +267,8 @@ module Dependabot
262
267
  next unless pkg_name
263
268
 
264
269
  if poetry_object[type][pkg_name].is_a?(Hash)
270
+ next unless poetry_object[type][pkg_name].key?("version") # skip enrichment-only entries
271
+
265
272
  poetry_object[type][pkg_name]["version"] = dep.version
266
273
  else
267
274
  poetry_object[type][pkg_name] = dep.version
@@ -284,9 +291,7 @@ module Dependabot
284
291
 
285
292
  sig { params(pyproject_content: String).returns(String) }
286
293
  def sanitize(pyproject_content)
287
- PyprojectPreparer
288
- .new(pyproject_content: pyproject_content)
289
- .sanitize
294
+ PyprojectPreparer.new(pyproject_content: pyproject_content).sanitize
290
295
  end
291
296
 
292
297
  sig { params(pyproject_content: String).returns(String) }
@@ -16,6 +16,80 @@ module Dependabot
16
16
  class PyprojectPreparer
17
17
  extend T::Sig
18
18
 
19
+ # Builds a regex pattern that matches a PEP 508 package name,
20
+ # treating hyphens, underscores, and dots as interchangeable per PEP 508.
21
+ sig { params(name: String).returns(String) }
22
+ def self.pep508_name_pattern(name)
23
+ Regexp.escape(name).gsub("\\-", "[-_.]").gsub("_", "[-_.]").gsub("\\.", "[-_.]")
24
+ end
25
+ private_class_method :pep508_name_pattern
26
+
27
+ # Pins a single PEP 508 dependency entry string to a specific version,
28
+ # preserving extras and environment markers.
29
+ sig { params(entry: String, name_pattern: String, version: String).returns(String) }
30
+ def self.pin_pep508_entry(entry, name_pattern, version)
31
+ if entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~]/i)
32
+ entry.sub(
33
+ /(?<pre>#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
34
+ "\\k<pre>==#{version}"
35
+ )
36
+ else
37
+ entry.sub(
38
+ /(?<pre>#{name_pattern}(?:\[.*?\])?)(?<rest>\s*(?:;.*)?)/i,
39
+ "\\k<pre>==#{version}\\k<rest>"
40
+ )
41
+ end
42
+ end
43
+ private_class_method :pin_pep508_entry
44
+
45
+ # Freezes PEP 621 dependencies in-place within a parsed pyproject object.
46
+ # Replaces version specifiers with ==dep.version for each matching dep.
47
+ # Accepts an optional block to filter which dependencies to freeze.
48
+ sig do
49
+ params(
50
+ pyproject_object: T::Hash[String, T.untyped],
51
+ deps: T::Array[Dependabot::Dependency],
52
+ blk: T.nilable(T.proc.params(dep: Dependabot::Dependency).returns(T::Boolean))
53
+ ).void
54
+ end
55
+ def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
56
+ dep_arrays = collect_pep621_dep_arrays(pyproject_object)
57
+ return if dep_arrays.empty?
58
+
59
+ deps.each do |dep|
60
+ next if blk && !yield(dep)
61
+ next unless dep.version
62
+
63
+ pin_pep621_dep_in_arrays!(dep_arrays, dep)
64
+ end
65
+ end
66
+
67
+ sig { params(pyproject_object: T::Hash[String, T.untyped]).returns(T::Array[T::Array[String]]) }
68
+ def self.collect_pep621_dep_arrays(pyproject_object)
69
+ project_object = pyproject_object["project"]
70
+ return [] unless project_object
71
+
72
+ dep_arrays = [project_object["dependencies"]]
73
+ project_object["optional-dependencies"]&.each_value { |opt| dep_arrays << opt }
74
+ dep_arrays.compact!
75
+ dep_arrays.select! { |arr| arr.is_a?(Array) && arr.all?(String) }
76
+ dep_arrays
77
+ end
78
+ private_class_method :collect_pep621_dep_arrays
79
+
80
+ sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
81
+ def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
82
+ name_pattern = pep508_name_pattern(dep.name)
83
+ dep_arrays.each do |arr|
84
+ arr.each_with_index do |entry, i|
85
+ next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*(\z|[><=!~;,])/i)
86
+
87
+ arr[i] = pin_pep508_entry(entry, name_pattern, T.must(dep.version))
88
+ end
89
+ end
90
+ end
91
+ private_class_method :pin_pep621_dep_in_arrays!
92
+
19
93
  sig { params(pyproject_content: String, lockfile: T.nilable(Dependabot::DependencyFile)).void }
20
94
  def initialize(pyproject_content:, lockfile: nil)
21
95
  @pyproject_content = pyproject_content
@@ -109,6 +183,9 @@ module Dependabot
109
183
  end
110
184
  end
111
185
 
186
+ # Freeze PEP 621 project.dependencies and project.optional-dependencies
187
+ freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
188
+
112
189
  TomlRB.dump(pyproject_object)
113
190
  end
114
191
  # rubocop:enable Metrics/AbcSize
@@ -122,6 +199,38 @@ module Dependabot
122
199
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
123
200
  attr_reader :lockfile
124
201
 
202
+ sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
203
+ def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
204
+ project_object = pyproject_object["project"]
205
+ return unless project_object
206
+
207
+ freeze_pep621_dep_array!(project_object["dependencies"], excluded_names)
208
+
209
+ project_object["optional-dependencies"]&.each_value do |opt_deps|
210
+ freeze_pep621_dep_array!(opt_deps, excluded_names)
211
+ end
212
+ end
213
+
214
+ sig { params(dep_array: T.nilable(T::Array[String]), excluded_names: T::Array[String]).void }
215
+ def freeze_pep621_dep_array!(dep_array, excluded_names)
216
+ return unless dep_array
217
+
218
+ dep_array.each_with_index do |entry, index|
219
+ # Extract dependency name from PEP 508 string
220
+ match = entry.match(/\A([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
221
+ next unless match
222
+
223
+ dep_name = normalise(T.must(match[1]))
224
+ next if excluded_names.include?(dep_name)
225
+
226
+ locked_details = locked_details(dep_name)
227
+ next unless (locked_version = locked_details&.fetch("version"))
228
+
229
+ name_pattern = self.class.send(:pep508_name_pattern, T.must(match[1]))
230
+ dep_array[index] = self.class.send(:pin_pep508_entry, entry, name_pattern, locked_version)
231
+ end
232
+ end
233
+
125
234
  sig { params(dep_name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
126
235
  def locked_details(dep_name)
127
236
  parsed_lockfile.fetch("package")
@@ -55,33 +55,56 @@ module Dependabot
55
55
 
56
56
  sig { returns(T::Array[Dependabot::DependencyFile]) }
57
57
  def fetch_updated_dependency_files
58
- reqs = dependency.requirements.zip(dependency.previous_requirements || [])
58
+ updated_contents = T.let({}, T::Hash[String, String])
59
+
60
+ unique_requirement_changes.each do |pair|
61
+ new_req = T.must(pair[0])
62
+ old_req = pair[1]
63
+ filename = new_req.fetch(:file)
64
+ content = updated_contents[filename] || T.must(T.must(get_original_file(filename)).content)
65
+ updated_contents[filename] = updated_requirement_or_setup_file_content(content, new_req, old_req)
66
+ end
67
+
68
+ updated_contents.filter_map do |filename, content|
69
+ file = T.must(get_original_file(filename)).dup
70
+ next if content == T.must(file.content)
71
+
72
+ file.content = content
73
+ file
74
+ end
75
+ end
76
+
77
+ # Deduplicates requirements that share the same file and requirement strings.
78
+ # The replacer's regex matches all extras variants at once, so one call per
79
+ # unique (file, old_requirement, new_requirement) is sufficient.
80
+ sig { returns(T::Array[T::Array[T.nilable(T::Hash[Symbol, T.untyped])]]) }
81
+ def unique_requirement_changes
82
+ previous_reqs = dependency.previous_requirements || []
59
83
 
60
- reqs.filter_map do |(new_req, old_req)|
84
+ changes = dependency.requirements.filter_map do |new_req|
85
+ old_req = previous_reqs.find { |r| r[:file] == new_req[:file] && r[:groups] == new_req[:groups] }
61
86
  next if new_req == old_req
62
87
 
63
- file = T.must(get_original_file(new_req.fetch(:file))).dup
64
- updated_content =
65
- updated_requirement_or_setup_file_content(new_req, old_req)
66
- next if updated_content == file.content
88
+ [new_req, old_req]
89
+ end
67
90
 
68
- file.content = updated_content
69
- file
91
+ changes.uniq do |pair|
92
+ new_req = pair[0]
93
+ old_req = pair[1]
94
+ [new_req[:file], old_req&.fetch(:requirement), new_req.fetch(:requirement)]
70
95
  end
71
96
  end
72
97
 
73
98
  sig do
74
99
  params(
100
+ content: String,
75
101
  new_req: T::Hash[Symbol, T.untyped],
76
102
  old_req: T.nilable(T::Hash[Symbol, T.untyped])
77
103
  ).returns(String)
78
104
  end
79
- def updated_requirement_or_setup_file_content(new_req, old_req)
80
- original_file = get_original_file(new_req.fetch(:file))
81
- raise "Could not find a dependency file for #{new_req}" unless original_file
82
-
105
+ def updated_requirement_or_setup_file_content(content, new_req, old_req)
83
106
  RequirementReplacer.new(
84
- content: T.must(original_file.content),
107
+ content: content,
85
108
  dependency_name: dependency.name,
86
109
  old_requirement: old_req&.fetch(:requirement),
87
110
  new_requirement: new_req.fetch(:requirement),
@@ -53,7 +53,7 @@ module Dependabot
53
53
  # ignore it, since it isn't actually a declaration
54
54
  next mtch if Regexp.last_match&.pre_match&.match?(/--.*\z/)
55
55
 
56
- updated_dependency_declaration_string
56
+ updated_matched_declaration(mtch)
57
57
  end
58
58
 
59
59
  raise "Expected content to change!" if old_requirement != new_requirement && content == updated_content
@@ -98,29 +98,30 @@ module Dependabot
98
98
  new_req_string
99
99
  end
100
100
 
101
- sig { returns(String) }
102
- def updated_dependency_declaration_string
103
- old_req = old_requirement
104
- updated_string =
105
- if old_req
106
- original_dependency_declaration_string(old_req)
107
- .sub(RequirementParser::REQUIREMENTS, updated_requirement_string || "")
108
- else
109
- original_dependency_declaration_string(old_req)
110
- .sub(RequirementParser::NAME_WITH_EXTRAS) do |nm|
111
- nm + (updated_requirement_string || "")
112
- end
113
- end
114
-
115
- return updated_string unless update_hashes? && requirement_includes_hashes?(old_req)
116
-
117
- updated_string.sub(
101
+ # Builds updated declaration from the actual matched text, preserving
102
+ # whatever extras (or lack thereof) appeared in the original match.
103
+ sig { params(matched_declaration: String).returns(String) }
104
+ def updated_matched_declaration(matched_declaration)
105
+ updated = if old_requirement
106
+ matched_declaration
107
+ .sub(RequirementParser::REQUIREMENTS, updated_requirement_string || "")
108
+ else
109
+ matched_declaration
110
+ .sub(RequirementParser::NAME_WITH_EXTRAS) { |nm| nm + (updated_requirement_string || "") }
111
+ end
112
+
113
+ return updated unless update_hashes? && matched_declaration.match?(RequirementParser::HASHES)
114
+
115
+ algorithm = T.must(matched_declaration.match(RequirementParser::HASHES))
116
+ .named_captures.fetch("algorithm")
117
+ separator = hash_separator(old_requirement)
118
+ updated.sub(
118
119
  RequirementParser::HASHES,
119
120
  package_hashes_for(
120
121
  name: dependency_name,
121
122
  version: new_hash_version,
122
- algorithm: hash_algorithm(old_req)
123
- ).join(hash_separator(old_req))
123
+ algorithm: algorithm
124
+ ).join(separator)
124
125
  )
125
126
  end
126
127
 
@@ -142,7 +143,14 @@ module Dependabot
142
143
  def original_declaration_replacement_regex
143
144
  original_string =
144
145
  original_dependency_declaration_string(old_requirement)
145
- /(?<![\-\w\.\[])#{Regexp.escape(original_string)}(?![\-\w\.])/
146
+ match_data = T.must(original_string.match(RequirementParser::NAME_WITH_EXTRAS))
147
+ name_escaped = Regexp.escape(T.must(match_data[:name]))
148
+ # Everything after name+extras (the requirement/markers/hashes portion)
149
+ after_name_extras = T.must(original_string[T.must(match_data[0]).length..]).strip
150
+ after_escaped = Regexp.escape(after_name_extras)
151
+ # Match the dependency name with any extras (or none), followed by the requirement.
152
+ # This ensures a single gsub handles all extras variants of the same dependency.
153
+ /(?<![\-\w\.\[])#{name_escaped}\s*\\?\s*(?:\[[^\]]*\])?\s*\\?\s*#{after_escaped}(?![\-\w\.])/
146
154
  end
147
155
 
148
156
  sig { params(requirement: T.nilable(String)).returns(T::Boolean) }