dependabot-python 0.366.0 → 0.367.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: 5e54cc0660ba9d6007661da639e66151ac7cdab9dafb2ea460ee544b825e7790
4
+ data.tar.gz: eaaec236cd949ffa2a446b5056a56378a2bcf954eb32da78e0ec0cb9f7b0bb4c
5
5
  SHA512:
6
- metadata.gz: 8f2863ace34a24b905087a738ded1e9113c91d2a3694a4192bfb9c2d85ab928192b142ce3551ea3bfeca3b787d34dfce34ed76d9dd95de04d1f4be17ef527257
7
- data.tar.gz: af23ec35a7c9f6499ee669e087911681e5b844ac223c86b2425e0d47a724ef45a7829c577085086ac582f07888288afb36584420ee9cf6871f0c0c6134714127
6
+ metadata.gz: 88f0d4fc4032adab9165c2d0187ef56660ebc219472723ad97c61c128e764cfeac49d4a49abfa4999f05ac85f9ccee3189dec442fc5701bf1afd41d18d706fba
7
+ data.tar.gz: 931b2880cdfda0184098b61416963e02542a13993752f83e8682f3c25b7de3e2f6c45affe5a80e231a331fc6bcd4b62b0eec3bab33aae065a92d70932e31c844
@@ -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" }
@@ -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 ||=
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.367.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.367.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.367.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.367.0
297
298
  rdoc_options: []
298
299
  require_paths:
299
300
  - lib