dependabot-python 0.403.0 → 0.404.0

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