dependabot-python 0.366.0 → 0.368.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: 3b498f046a4fb1abdd22aa02f941fae26c33c3f270a7211de5d84b7a6c940314
4
- data.tar.gz: ea3de508f569a3b1d84380bce00db482973fdffedb88c8e0cbe4e43e6c684778
3
+ metadata.gz: 5b03d84191a73f9cd803b3db1fd32bc70638a4f607c2e9424a3900f143119f5f
4
+ data.tar.gz: 6d04a235c38eb76a1a86eb2f60e2f34c80c82ff5f342d997ff3c8a25011e42f3
5
5
  SHA512:
6
- metadata.gz: 8f2863ace34a24b905087a738ded1e9113c91d2a3694a4192bfb9c2d85ab928192b142ce3551ea3bfeca3b787d34dfce34ed76d9dd95de04d1f4be17ef527257
7
- data.tar.gz: af23ec35a7c9f6499ee669e087911681e5b844ac223c86b2425e0d47a724ef45a7829c577085086ac582f07888288afb36584420ee9cf6871f0c0c6134714127
6
+ metadata.gz: 793a849e50f2162b65f1da65521dfb7450081b84507fbb4134145ab5c72b63a916a59c68602bf41dea530d373cb0d2dd5e9a38fee9b6a5347b17848a8891054f
7
+ data.tar.gz: db96c2fd7be7c4b6ac473f29dc429f3c4163fb2dd38d4b1d48c19ea62727022ff6822166dd10e9c4a680eae24bc5f16f447bf9f8ec55b7909b8ae663698b29c6
@@ -0,0 +1,131 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "dependabot/dependency_file"
7
+ require "dependabot/shared_helpers"
8
+ require "dependabot/python/file_parser/python_requirement_parser"
9
+ require "dependabot/python/language_version_manager"
10
+
11
+ module Dependabot
12
+ module Python
13
+ class DependencyGrapher < Dependabot::DependencyGraphers::Base
14
+ class LockfileGenerator
15
+ extend T::Sig
16
+
17
+ LOCKFILE_NAME = "poetry.lock"
18
+
19
+ sig do
20
+ params(
21
+ dependency_files: T::Array[Dependabot::DependencyFile],
22
+ credentials: T::Array[Dependabot::Credential]
23
+ ).void
24
+ end
25
+ def initialize(dependency_files:, credentials:)
26
+ @dependency_files = dependency_files
27
+ @credentials = credentials
28
+ end
29
+
30
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
31
+ def generate
32
+ SharedHelpers.in_a_temporary_directory do
33
+ SharedHelpers.with_git_configured(credentials: credentials) do
34
+ write_temporary_files
35
+ language_version_manager.install_required_python
36
+ run_poetry_lock
37
+ read_generated_lockfile
38
+ end
39
+ end
40
+ rescue SharedHelpers::HelperSubprocessFailed => e
41
+ handle_generation_error(e)
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
48
+ attr_reader :dependency_files
49
+
50
+ sig { returns(T::Array[Dependabot::Credential]) }
51
+ attr_reader :credentials
52
+
53
+ sig { void }
54
+ def write_temporary_files
55
+ dependency_files.each do |file|
56
+ path = file.name
57
+ FileUtils.mkdir_p(File.dirname(path))
58
+ File.write(path, file.content)
59
+ end
60
+
61
+ File.write(".python-version", language_version_manager.python_major_minor)
62
+ end
63
+
64
+ sig { void }
65
+ def run_poetry_lock
66
+ Dependabot.logger.info("Generating poetry.lock for dependency graphing")
67
+
68
+ # Use system git instead of the pure Python dulwich
69
+ run_poetry_command("pyenv exec poetry config system-git-client true")
70
+
71
+ # --no-interaction avoids password prompts
72
+ run_poetry_command("pyenv exec poetry lock --no-interaction")
73
+ end
74
+
75
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
76
+ def read_generated_lockfile
77
+ unless File.exist?(LOCKFILE_NAME)
78
+ Dependabot.logger.warn("#{LOCKFILE_NAME} was not generated")
79
+ return nil
80
+ end
81
+
82
+ content = File.read(LOCKFILE_NAME)
83
+
84
+ Dependabot::DependencyFile.new(
85
+ name: LOCKFILE_NAME,
86
+ content: content,
87
+ directory: pyproject_directory
88
+ )
89
+ end
90
+
91
+ sig { returns(String) }
92
+ def pyproject_directory
93
+ pyproject = dependency_files.find { |f| f.name == "pyproject.toml" }
94
+ pyproject&.directory || "/"
95
+ end
96
+
97
+ sig { params(command: String).returns(String) }
98
+ def run_poetry_command(command)
99
+ SharedHelpers.run_shell_command(command, fingerprint: command)
100
+ end
101
+
102
+ sig { returns(LanguageVersionManager) }
103
+ def language_version_manager
104
+ @language_version_manager ||= T.let(
105
+ LanguageVersionManager.new(
106
+ python_requirement_parser: python_requirement_parser
107
+ ),
108
+ T.nilable(LanguageVersionManager)
109
+ )
110
+ end
111
+
112
+ sig { returns(FileParser::PythonRequirementParser) }
113
+ def python_requirement_parser
114
+ @python_requirement_parser ||= T.let(
115
+ FileParser::PythonRequirementParser.new(
116
+ dependency_files: dependency_files
117
+ ),
118
+ T.nilable(FileParser::PythonRequirementParser)
119
+ )
120
+ end
121
+
122
+ sig { params(error: SharedHelpers::HelperSubprocessFailed).void }
123
+ def handle_generation_error(error)
124
+ Dependabot.logger.error(
125
+ "Failed to generate #{LOCKFILE_NAME}: #{error.message}"
126
+ )
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -16,12 +16,14 @@ require "toml-rb"
16
16
  module Dependabot
17
17
  module Python
18
18
  class DependencyGrapher < Dependabot::DependencyGraphers::Base
19
+ require_relative "dependency_grapher/lockfile_generator"
20
+
19
21
  sig { override.returns(Dependabot::DependencyFile) }
20
22
  def relevant_dependency_file
21
23
  dependency_files_by_package_manager = T.let(
22
24
  {
23
25
  PipenvPackageManager::NAME => [pipfile_lock, pipfile],
24
- PoetryPackageManager::NAME => [poetry_lock, pyproject_toml],
26
+ PoetryPackageManager::NAME => [committed_poetry_lock, pyproject_toml],
25
27
  PipCompilePackageManager::NAME => [pip_compile_lockfile, pip_compile_manifest, pyproject_toml],
26
28
  PipPackageManager::NAME => [pip_requirements_file, pyproject_toml, pipfile_lock, pipfile, setup_file,
27
29
  setup_cfg_file]
@@ -36,13 +38,87 @@ module Dependabot
36
38
  raise DependabotError, "No supported dependency file present."
37
39
  end
38
40
 
41
+ sig { override.void }
42
+ def prepare!
43
+ if poetry_project_without_lockfile?
44
+ Dependabot.logger.info("No poetry.lock found, generating ephemeral lockfile for dependency graphing")
45
+ generate_ephemeral_lockfile!
46
+ emit_missing_lockfile_warning! if @ephemeral_lockfile_generated
47
+ end
48
+ super
49
+ end
50
+
39
51
  private
40
52
 
53
+ # Returns the poetry.lock only if it was committed to the repo,
54
+ # not if it was generated ephemerally. This ensures that
55
+ # relevant_dependency_file reports the real manifest (pyproject.toml).
56
+ sig { returns(T.nilable(Dependabot::DependencyFile)) }
57
+ def committed_poetry_lock
58
+ return nil if @ephemeral_lockfile_generated
59
+
60
+ poetry_lock
61
+ end
62
+
63
+ # The file parser only identifies Poetry when poetry.lock is present,
64
+ # so we detect it independently by checking for [tool.poetry] in pyproject.toml.
65
+ # Within the python image, no other package manager uses this section
66
+ # (uv runs in a separate image).
67
+ sig { returns(T::Boolean) }
68
+ def poetry_project_without_lockfile?
69
+ return false if poetry_lock
70
+ return false unless pyproject_toml
71
+
72
+ parsed = TomlRB.parse(T.must(pyproject_toml&.content))
73
+ !parsed.dig("tool", "poetry").nil?
74
+ rescue TomlRB::ParseError, TomlRB::ValueOverwriteError
75
+ false
76
+ end
77
+
41
78
  sig { returns(String) }
42
79
  def python_package_manager
43
80
  T.must(file_parser.ecosystem).package_manager.name
44
81
  end
45
82
 
83
+ sig { void }
84
+ def generate_ephemeral_lockfile!
85
+ generator = LockfileGenerator.new(
86
+ dependency_files: dependency_files,
87
+ credentials: file_parser.credentials
88
+ )
89
+
90
+ ephemeral_lockfile = generator.generate
91
+ return unless ephemeral_lockfile
92
+
93
+ inject_ephemeral_lockfile(ephemeral_lockfile)
94
+ @ephemeral_lockfile_generated = T.let(true, T.nilable(T::Boolean))
95
+
96
+ Dependabot.logger.info(
97
+ "Successfully generated ephemeral #{ephemeral_lockfile.name} for dependency graphing"
98
+ )
99
+ rescue StandardError => e
100
+ Dependabot.logger.warn(
101
+ "Failed to generate ephemeral lockfile: #{e.message}. " \
102
+ "Dependency versions may not be resolved."
103
+ )
104
+ end
105
+
106
+ sig { params(ephemeral_lockfile: Dependabot::DependencyFile).void }
107
+ def inject_ephemeral_lockfile(ephemeral_lockfile)
108
+ dependency_files << ephemeral_lockfile
109
+ end
110
+
111
+ sig { void }
112
+ def emit_missing_lockfile_warning!
113
+ Dependabot.logger.warn(
114
+ "No poetry.lock was found in this repository. " \
115
+ "Dependabot generated a temporary lockfile to determine exact dependency versions.\n\n" \
116
+ "To ensure consistent builds and security scanning, we recommend committing your poetry.lock file. " \
117
+ "Without a committed lockfile, resolved dependency versions may change between scans " \
118
+ "due to new package releases."
119
+ )
120
+ end
121
+
46
122
  sig { override.params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
47
123
  def fetch_subdependencies(dependency)
48
124
  package_relationships.fetch(dependency.name, []).select { |child| dependency_name_set.include?(child) }
@@ -46,11 +46,7 @@ module Dependabot
46
46
  def pyproject_dependencies
47
47
  dependencies = Dependabot::FileParsers::Base::DependencySet.new
48
48
 
49
- # Parse Poetry dependencies if [tool.poetry] section exists
50
49
  dependencies += poetry_dependencies if using_poetry?
51
-
52
- # Parse PEP 621/735 dependencies if those sections exist
53
- # This handles hybrid projects that have both Poetry and PEP 621 sections
54
50
  dependencies += pep621_pep735_dependencies if using_pep621? || using_pep735?
55
51
 
56
52
  dependencies
@@ -101,7 +97,7 @@ module Dependabot
101
97
 
102
98
  dependencies <<
103
99
  Dependency.new(
104
- name: normalised_name(dep["name"], dep["extras"]),
100
+ name: normalise(dep["name"]),
105
101
  version: dep["version"]&.include?("*") ? nil : dep["version"],
106
102
  requirements: [{
107
103
  requirement: dep["requirement"],
@@ -109,7 +105,8 @@ module Dependabot
109
105
  source: nil,
110
106
  groups: [dep["requirement_type"]].compact
111
107
  }],
112
- package_manager: "pip"
108
+ package_manager: "pip",
109
+ metadata: extras_metadata(dep["extras"])
113
110
  )
114
111
  end
115
112
 
@@ -147,6 +144,16 @@ module Dependabot
147
144
  NameNormaliser.normalise_including_extras(name, extras)
148
145
  end
149
146
 
147
+ # Build metadata hash storing extras as a comma-separated string.
148
+ # Stored in metadata so the file updater can reconstruct the full
149
+ # PEP 621 declaration (e.g. "cachecontrol[filecache]>=0.14.0").
150
+ sig { params(extras: T::Array[String]).returns(T::Hash[Symbol, String]) }
151
+ def extras_metadata(extras)
152
+ return {} if extras.empty?
153
+
154
+ { extras: extras.join(",") }
155
+ end
156
+
150
157
  # @param req can be an Array, Hash or String that represents the constraints for a dependency
151
158
  sig { params(req: T.untyped, type: String).returns(T::Array[T::Hash[Symbol, T.nilable(String)]]) }
152
159
  # rubocop:disable Metrics/PerceivedComplexity
@@ -43,7 +43,7 @@ module Dependabot
43
43
 
44
44
  dependencies <<
45
45
  Dependency.new(
46
- name: normalised_name(dep["name"], dep["extras"]),
46
+ name: normalise(dep["name"]),
47
47
  version: dep["version"]&.include?("*") ? nil : dep["version"],
48
48
  requirements: [{
49
49
  requirement: dep["requirement"],
@@ -51,7 +51,8 @@ module Dependabot
51
51
  source: nil,
52
52
  groups: [dep["requirement_type"]]
53
53
  }],
54
- package_manager: "pip"
54
+ package_manager: "pip",
55
+ metadata: extras_metadata(dep["extras"])
55
56
  )
56
57
  end
57
58
  dependencies
@@ -184,6 +185,18 @@ module Dependabot
184
185
  NameNormaliser.normalise_including_extras(name, extras)
185
186
  end
186
187
 
188
+ sig { params(name: String).returns(String) }
189
+ def normalise(name)
190
+ NameNormaliser.normalise(name)
191
+ end
192
+
193
+ sig { params(extras: T::Array[String]).returns(T::Hash[Symbol, String]) }
194
+ def extras_metadata(extras)
195
+ return {} if extras.empty?
196
+
197
+ { extras: extras.join(",") }
198
+ end
199
+
187
200
  sig { returns(T.untyped) }
188
201
  def setup_file
189
202
  dependency_files.find { |f| f.name == "setup.py" }
@@ -293,10 +293,11 @@ module Dependabot
293
293
 
294
294
  dependencies <<
295
295
  Dependency.new(
296
- name: normalised_name(name, dep["extras"]),
296
+ name: NameNormaliser.normalise(name),
297
297
  version: version&.include?("*") ? nil : version,
298
298
  requirements: requirements,
299
- package_manager: "pip"
299
+ package_manager: "pip",
300
+ metadata: extras_metadata(dep["extras"])
300
301
  )
301
302
  end
302
303
  dependencies
@@ -467,6 +468,13 @@ module Dependabot
467
468
  NameNormaliser.normalise_including_extras(name, extras)
468
469
  end
469
470
 
471
+ sig { params(extras: T::Array[String]).returns(T::Hash[Symbol, String]) }
472
+ def extras_metadata(extras)
473
+ return {} if extras.empty?
474
+
475
+ { extras: extras.join(",") }
476
+ end
477
+
470
478
  sig { override.returns(T.untyped) }
471
479
  def check_required_files
472
480
  filenames = dependency_files.map(&:name)
@@ -49,7 +49,6 @@ module Dependabot
49
49
  @language_version_manager = T.let(nil, T.nilable(LanguageVersionManager))
50
50
  @python_requirement_parser = T.let(nil, T.nilable(FileParser::PythonRequirementParser))
51
51
  @updated_pyproject_content = T.let(nil, T.nilable(String))
52
- @python_helper_path = T.let(nil, T.nilable(String))
53
52
  @poetry_lock = T.let(nil, T.nilable(Dependabot::DependencyFile))
54
53
  end
55
54
 
@@ -363,7 +362,7 @@ module Dependabot
363
362
  write_temporary_dependency_files(pyproject_content)
364
363
 
365
364
  SharedHelpers.run_helper_subprocess(
366
- command: "pyenv exec python3 #{python_helper_path}",
365
+ command: "pyenv exec python3 #{NativeHelpers.python_helper_path}",
367
366
  function: "get_pyproject_hash",
368
367
  args: [T.cast(dir, Pathname).to_s]
369
368
  )
@@ -386,7 +385,16 @@ module Dependabot
386
385
 
387
386
  sig { params(dep: Dependabot::Dependency, old_req: String).returns(Regexp) }
388
387
  def pep621_declaration_regex(dep, old_req)
389
- /(?<declaration>["']#{escape(dep)}#{Regexp.escape(old_req)}["'])/mi
388
+ /(?<declaration>["']#{escape(dep)}#{extras_pattern(dep)}#{Regexp.escape(old_req)}["'])/mi
389
+ end
390
+
391
+ # Reconstructs extras from metadata for PEP 621 regex matching.
392
+ sig { params(dep: Dependabot::Dependency).returns(String) }
393
+ def extras_pattern(dep)
394
+ extras_str = dep.metadata[:extras]
395
+ return "" unless extras_str.is_a?(String) && !extras_str.empty?
396
+
397
+ "\\[" + extras_str.split(",").map { |e| Regexp.escape(e.strip) }.join(",\\s*") + "\\]"
390
398
  end
391
399
 
392
400
  sig { params(dep: Dependency).returns(String) }
@@ -446,11 +454,6 @@ module Dependabot
446
454
  @lockfile ||= poetry_lock
447
455
  end
448
456
 
449
- sig { returns(String) }
450
- def python_helper_path
451
- NativeHelpers.python_helper_path
452
- end
453
-
454
457
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
455
458
  def poetry_lock
456
459
  dependency_files.find { |f| f.name == "poetry.lock" }
@@ -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) }
@@ -64,7 +64,7 @@ module Dependabot
64
64
  next unless first[:groups]
65
65
  .include?("install_requires")
66
66
 
67
- dep.name + first[:requirement].to_s
67
+ name_with_extras(dep) + first[:requirement].to_s
68
68
  end
69
69
  end
70
70
 
@@ -76,7 +76,7 @@ module Dependabot
76
76
  next unless first[:groups]
77
77
  .include?("setup_requires")
78
78
 
79
- dep.name + first[:requirement].to_s
79
+ name_with_extras(dep) + first[:requirement].to_s
80
80
  end
81
81
  end
82
82
 
@@ -92,7 +92,7 @@ module Dependabot
92
92
 
93
93
  hash[group.split(":").last] ||= []
94
94
  hash[group.split(":").last] <<
95
- (dep.name + first[:requirement].to_s)
95
+ (name_with_extras(dep) + first[:requirement].to_s)
96
96
  end
97
97
  end
98
98
 
@@ -100,6 +100,15 @@ module Dependabot
100
100
  end
101
101
  end
102
102
 
103
+ # Reconstruct dependency name with extras from metadata.
104
+ sig { params(dep: Dependabot::Dependency).returns(String) }
105
+ def name_with_extras(dep)
106
+ extras_str = dep.metadata[:extras]
107
+ return dep.name unless extras_str.is_a?(String) && !extras_str.empty?
108
+
109
+ "#{dep.name}[#{extras_str}]"
110
+ end
111
+
103
112
  sig { returns(Dependabot::FileParsers::Base::DependencySet) }
104
113
  def parsed_setup_file
105
114
  @parsed_setup_file ||=
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "excon"
5
+ require "openssl"
5
6
  require "uri"
6
7
 
7
8
  require "dependabot/metadata_finders"
@@ -27,6 +28,7 @@ module Dependabot
27
28
  def initialize(dependency:, credentials:)
28
29
  super
29
30
  @pypi_listing = T.let(nil, T.nilable(T::Hash[String, T.untyped]))
31
+ @parsed_source_urls = T.let({}, T::Hash[String, T.nilable(Dependabot::Source)])
30
32
  end
31
33
 
32
34
  sig { returns(T.nilable(String)) }
@@ -37,26 +39,88 @@ module Dependabot
37
39
  super
38
40
  end
39
41
 
42
+ sig { override.returns(T.nilable(String)) }
43
+ def maintainer_changes
44
+ return unless dependency.previous_version
45
+ return unless dependency.version
46
+
47
+ previous_ownership = ownership_for_version(T.must(dependency.previous_version))
48
+ current_ownership = ownership_for_version(T.must(dependency.version))
49
+
50
+ if previous_ownership.nil? || current_ownership.nil?
51
+ Dependabot.logger.info("Unable to determine ownership changes for #{dependency.name}")
52
+ return
53
+ end
54
+
55
+ previous_org = previous_ownership["organization"]
56
+ current_org = current_ownership["organization"]
57
+
58
+ if previous_org != current_org && !(previous_org.nil? && current_org)
59
+ return "The organization that maintains #{dependency.name} on PyPI has " \
60
+ "changed since your current version."
61
+ end
62
+
63
+ previous_users = ownership_users(previous_ownership)
64
+ current_users = ownership_users(current_ownership)
65
+
66
+ # Warn only when there were previous maintainers and none of them remain
67
+ return unless previous_users.any? && !previous_users.intersect?(current_users)
68
+
69
+ "None of the maintainers for your current version of #{dependency.name} are " \
70
+ "listed as maintainers for the new version on PyPI."
71
+ end
72
+
40
73
  private
41
74
 
42
75
  sig { override.returns(T.nilable(Dependabot::Source)) }
43
76
  def look_up_source
77
+ source_url = exact_match_source_url_from_project_urls
78
+ source_url ||= labelled_source_url_from_project_urls
79
+ source_url ||= fallback_source_url
80
+ source_url ||= source_from_description
81
+ source_url ||= source_from_homepage
82
+
83
+ parsed_source_from_url(source_url)
84
+ end
85
+
86
+ sig { returns(T.nilable(String)) }
87
+ def exact_match_source_url_from_project_urls
88
+ project_urls.values.find do |url|
89
+ repo = parsed_source_from_url(url)&.repo
90
+ repo&.downcase&.end_with?(normalised_dependency_name)
91
+ end
92
+ end
93
+
94
+ sig { returns(T.nilable(String)) }
95
+ def labelled_source_url_from_project_urls
96
+ source_urls = source_like_project_url_labels.filter_map do |label|
97
+ project_urls[label]
98
+ end
99
+
100
+ source_urls.find { |url| parsed_source_from_url(url) }
101
+ end
102
+
103
+ sig { returns(T.nilable(String)) }
104
+ def fallback_source_url
44
105
  potential_source_urls = [
45
- pypi_listing.dig("info", "project_urls", "Source"),
46
- pypi_listing.dig("info", "project_urls", "Repository"),
47
106
  pypi_listing.dig("info", "home_page"),
48
107
  pypi_listing.dig("info", "download_url"),
49
108
  pypi_listing.dig("info", "docs_url")
50
109
  ].compact
51
110
 
52
- potential_source_urls +=
53
- (pypi_listing.dig("info", "project_urls") || {}).values
111
+ potential_source_urls += project_urls.values
54
112
 
55
- source_url = potential_source_urls.find { |url| Source.from_url(url) }
56
- source_url ||= source_from_description
57
- source_url ||= source_from_homepage
113
+ potential_source_urls.find { |url| parsed_source_from_url(url) }
114
+ end
58
115
 
59
- Source.from_url(source_url)
116
+ sig { returns(T::Hash[String, String]) }
117
+ def project_urls
118
+ pypi_listing.dig("info", "project_urls") || {}
119
+ end
120
+
121
+ sig { returns(T::Array[String]) }
122
+ def source_like_project_url_labels
123
+ ["Source", "Source Code", "Repository", "Code", "Homepage"]
60
124
  end
61
125
 
62
126
  # rubocop:disable Metrics/PerceivedComplexity
@@ -73,7 +137,7 @@ module Dependabot
73
137
  # Looking for a source where the repo name exactly matches the
74
138
  # dependency name
75
139
  match_url = potential_source_urls.find do |url|
76
- repo = Source.from_url(url)&.repo
140
+ repo = parsed_source_from_url(url)&.repo
77
141
  repo&.downcase&.end_with?(normalised_dependency_name)
78
142
  end
79
143
 
@@ -83,11 +147,14 @@ module Dependabot
83
147
  # mentioned when the link is followed
84
148
  @source_from_description ||= T.let(
85
149
  potential_source_urls.find do |url|
86
- full_url = Source.from_url(url)&.url
150
+ full_url = parsed_source_from_url(url)&.url
87
151
  next unless full_url
88
152
 
89
153
  response = Dependabot::RegistryClient.get(url: full_url)
90
- next unless response.status == 200
154
+ unless response.status == 200
155
+ Dependabot.logger.warn("Error fetching source URL #{full_url}: HTTP #{response.status}")
156
+ next
157
+ end
91
158
 
92
159
  response.body.include?(normalised_dependency_name)
93
160
  end,
@@ -108,7 +175,7 @@ module Dependabot
108
175
  end
109
176
 
110
177
  match_url = potential_source_urls.find do |url|
111
- repo = Source.from_url(url)&.repo
178
+ repo = parsed_source_from_url(url)&.repo
112
179
  repo&.downcase&.end_with?(normalised_dependency_name)
113
180
  end
114
181
 
@@ -116,7 +183,7 @@ module Dependabot
116
183
 
117
184
  @source_from_homepage ||= T.let(
118
185
  potential_source_urls.find do |url|
119
- full_url = Source.from_url(url)&.url
186
+ full_url = parsed_source_from_url(url)&.url
120
187
  next unless full_url
121
188
 
122
189
  response = Dependabot::RegistryClient.get(url: full_url)
@@ -143,7 +210,8 @@ module Dependabot
143
210
  begin
144
211
  Dependabot::RegistryClient.get(url: homepage_url)
145
212
  rescue Excon::Error::Timeout, Excon::Error::Socket,
146
- Excon::Error::TooManyRedirects, ArgumentError
213
+ Excon::Error::TooManyRedirects, OpenSSL::SSL::SSLError, ArgumentError => e
214
+ Dependabot.logger.warn("Error fetching Python homepage URL #{homepage_url}: #{e.class}: #{e.message}")
147
215
  nil
148
216
  end,
149
217
  T.nilable(Excon::Response)
@@ -165,9 +233,8 @@ module Dependabot
165
233
 
166
234
  @pypi_listing = JSON.parse(response.body)
167
235
  return @pypi_listing
168
- rescue JSON::ParserError
169
- next
170
- rescue Excon::Error::Timeout
236
+ rescue JSON::ParserError, Excon::Error::Timeout, Excon::Error::Socket, OpenSSL::SSL::SSLError => e
237
+ Dependabot.logger.warn("Error fetching Python package listing from #{url}: #{e.class}: #{e.message}")
171
238
  next
172
239
  end
173
240
 
@@ -194,23 +261,88 @@ module Dependabot
194
261
 
195
262
  sig { returns(T::Array[String]) }
196
263
  def possible_listing_urls
197
- credential_urls =
264
+ index_credentials =
198
265
  credentials
199
266
  .select { |cred| cred["type"] == "python_index" }
200
- .map { |c| AuthedUrlBuilder.authed_url(credential: c) }
201
267
 
202
- (credential_urls + [MAIN_PYPI_URL]).map do |base_url|
268
+ credential_urls = index_credentials
269
+ .map { |c| AuthedUrlBuilder.authed_url(credential: c) }
270
+ .reject { |url| url.strip.empty? }
271
+
272
+ base_urls = if index_credentials.any?(&:replaces_base?)
273
+ credential_urls
274
+ else
275
+ credential_urls + [MAIN_PYPI_URL]
276
+ end
277
+ base_urls = [MAIN_PYPI_URL] if base_urls.empty?
278
+
279
+ base_urls.map do |base_url|
203
280
  # Convert /simple/ endpoints to /pypi/ for JSON API access
204
281
  json_base_url = base_url.sub(%r{/simple/?$}i, "/pypi")
205
282
  json_base_url.gsub(%r{/$}, "") + "/#{normalised_dependency_name}/json"
206
283
  end
207
284
  end
208
285
 
286
+ sig { params(version: String).returns(T.nilable(T::Hash[String, T.untyped])) }
287
+ def ownership_for_version(version)
288
+ if version.include?("+")
289
+ Dependabot.logger.info("Version #{version} includes a local version identifier, skipping ownership check")
290
+ return nil
291
+ end
292
+
293
+ possible_version_listing_urls(version).each do |url|
294
+ response = fetch_authed_url(url)
295
+ unless response.status == 200
296
+ Dependabot.logger.warn(
297
+ "Error fetching Python package ownership from #{url} for version #{version}: " \
298
+ "HTTP #{response.status}"
299
+ )
300
+ next
301
+ end
302
+
303
+ data = JSON.parse(response.body)
304
+ ownership = data["ownership"]
305
+ Dependabot.logger.debug("Found ownership for #{dependency.name} version #{version}")
306
+ return ownership
307
+ rescue JSON::ParserError, Excon::Error::Timeout, Excon::Error::Socket,
308
+ Excon::Error::TooManyRedirects, OpenSSL::SSL::SSLError, ArgumentError => e
309
+ Dependabot.logger.warn(
310
+ "Error fetching Python package ownership from #{url} for version #{version}: #{e.class}: #{e.message}"
311
+ )
312
+ next
313
+ end
314
+
315
+ nil
316
+ end
317
+
318
+ sig { params(version: String).returns(T::Array[String]) }
319
+ def possible_version_listing_urls(version)
320
+ possible_listing_urls.map do |url|
321
+ url.sub(%r{/json$}, "/#{URI::DEFAULT_PARSER.escape(version)}/json")
322
+ end
323
+ end
324
+
325
+ sig { params(ownership: T::Hash[String, T.untyped]).returns(T::Array[String]) }
326
+ def ownership_users(ownership)
327
+ roles = ownership["roles"]
328
+ return [] unless roles.is_a?(Array)
329
+
330
+ roles.filter_map { |role| role["user"] if role.is_a?(Hash) }
331
+ end
332
+
209
333
  # Strip [extras] from name (dependency_name[extra_dep,other_extra])
210
334
  sig { returns(String) }
211
335
  def normalised_dependency_name
212
336
  NameNormaliser.normalise(dependency.name)
213
337
  end
338
+
339
+ sig { params(url: T.nilable(String)).returns(T.nilable(Dependabot::Source)) }
340
+ def parsed_source_from_url(url)
341
+ return unless url
342
+ return @parsed_source_urls[url] if @parsed_source_urls.key?(url)
343
+
344
+ @parsed_source_urls[url] = Source.from_url(url)
345
+ end
214
346
  end
215
347
  end
216
348
  end
@@ -238,7 +238,8 @@ module Dependabot
238
238
  content = file.content
239
239
  return [] if content.nil?
240
240
 
241
- paths = content.scan(CHILD_REQUIREMENT_REGEX).flatten
241
+ paths = content.scan(CHILD_REQUIREMENT_REGEX).flatten +
242
+ content.scan(CONSTRAINT_REGEX).flatten
242
243
  current_dir = File.dirname(file.name)
243
244
 
244
245
  paths.flat_map do |path|
@@ -268,7 +269,8 @@ module Dependabot
268
269
  sig { returns(T::Array[Dependabot::DependencyFile]) }
269
270
  def constraints_files
270
271
  all_requirement_files = requirements_txt_files +
271
- child_requirement_txt_files
272
+ child_requirement_txt_files +
273
+ requirements_in_files
272
274
 
273
275
  constraints_paths = all_requirement_files.map do |req_file|
274
276
  current_dir = File.dirname(req_file.name)
@@ -283,7 +285,10 @@ module Dependabot
283
285
  end
284
286
  end.flatten.uniq
285
287
 
286
- constraints_paths.map { |path| fetch_file_from_host(path) }
288
+ already_fetched_names = child_requirement_files.map(&:name)
289
+ constraints_paths
290
+ .reject { |path| already_fetched_names.include?(path) }
291
+ .map { |path| fetch_file_from_host(path) }
287
292
  end
288
293
 
289
294
  sig { returns(T::Array[Dependabot::DependencyFile]) }
@@ -355,7 +360,7 @@ module Dependabot
355
360
 
356
361
  uneditable_reqs =
357
362
  content
358
- .scan(/(?<name>^['"]?(?:file:)?(?<path>\.[^\[#'"\n]*))/)
363
+ .scan(/(?<name>^['"]?(?:file:)?(?<path>\.[^\[#'"\n;]*))/)
359
364
  .filter_map do |match_array|
360
365
  n, p = match_array
361
366
  { name: n.to_s.strip, path: p.to_s.strip, file: req_file.name } unless p.to_s.include?("://")
@@ -363,7 +368,7 @@ module Dependabot
363
368
 
364
369
  editable_reqs =
365
370
  content
366
- .scan(/(?<name>^-e\s+['"]?(?:file:)?(?<path>[^\[#'"\n]*))/)
371
+ .scan(/(?<name>^-e\s+['"]?(?:file:)?(?<path>[^\[#'"\n;]*))/)
367
372
  .filter_map do |match_array|
368
373
  n, p = match_array
369
374
  unless p.to_s.include?("://") || p.to_s.include?("git@")
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.366.0
4
+ version: 0.368.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.366.0
18
+ version: 0.368.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.366.0
25
+ version: 0.368.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -250,6 +250,7 @@ files:
250
250
  - lib/dependabot/python.rb
251
251
  - lib/dependabot/python/authed_url_builder.rb
252
252
  - lib/dependabot/python/dependency_grapher.rb
253
+ - lib/dependabot/python/dependency_grapher/lockfile_generator.rb
253
254
  - lib/dependabot/python/file_fetcher.rb
254
255
  - lib/dependabot/python/file_parser.rb
255
256
  - lib/dependabot/python/file_parser/pipfile_files_parser.rb
@@ -293,7 +294,7 @@ licenses:
293
294
  - MIT
294
295
  metadata:
295
296
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
296
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.366.0
297
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.368.0
297
298
  rdoc_options: []
298
299
  require_paths:
299
300
  - lib