dependabot-conda 0.325.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3f7c158f585649b7dd0c70aad680932fb5925fe91a19bb969edef4060a581104
4
+ data.tar.gz: a20edf95149380ede0798e4054504f4a829c9ce614c4ee363b2baeea71590fcb
5
+ SHA512:
6
+ metadata.gz: 2da367713845fd1065fdf607fb1835a3f59d1a980b4fa389fa92e79b0fba45bb7b614aabcb127c572c658bf8c1c822c8be64f546f6ef1b155cf5f94b906b5f4a
7
+ data.tar.gz: 32128d133c34b2ee87579c602f6d012c88f299ef45618e93042b9342460900161802c2cf393095cf8eef904394d8e3fb29cac24c044ea57fcd29208bbb7cfd11
@@ -0,0 +1,153 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/file_fetchers"
6
+ require "dependabot/file_fetchers/base"
7
+ require "dependabot/conda/python_package_classifier"
8
+
9
+ module Dependabot
10
+ module Conda
11
+ class FileFetcher < Dependabot::FileFetchers::Base
12
+ extend T::Sig
13
+
14
+ ENVIRONMENT_FILE_NAMES = T.let(%w(
15
+ environment.yml
16
+ environment.yaml
17
+ ).freeze, T::Array[String])
18
+
19
+ sig { override.params(filenames: T::Array[String]).returns(T::Boolean) }
20
+ def self.required_files_in?(filenames)
21
+ filenames.any? { |filename| ENVIRONMENT_FILE_NAMES.include?(filename) }
22
+ end
23
+
24
+ sig { override.returns(String) }
25
+ def self.required_files_message
26
+ "Repo must contain an environment.yml or environment.yaml file."
27
+ end
28
+
29
+ sig { override.returns(T::Array[DependencyFile]) }
30
+ def fetch_files
31
+ unless allow_beta_ecosystems?
32
+ raise Dependabot::DependencyFileNotFound.new(
33
+ nil,
34
+ "Conda support is currently in beta. Set ALLOW_BETA_ECOSYSTEMS=true to enable it."
35
+ )
36
+ end
37
+
38
+ fetched_files = []
39
+
40
+ ENVIRONMENT_FILE_NAMES.each do |filename|
41
+ environment_file = fetch_file_if_present(filename)
42
+ fetched_files << environment_file if environment_file
43
+ end
44
+
45
+ # If no environment files found, return empty (will cause appropriate error)
46
+ return fetched_files if fetched_files.empty?
47
+
48
+ # Validate that at least one environment file contains manageable Python packages
49
+ fetched_files.each do |file|
50
+ return fetched_files if environment_contains_manageable_packages?(file)
51
+ end
52
+
53
+ raise Dependabot::DependencyFileNotFound, unsupported_environment_message
54
+ end
55
+
56
+ private
57
+
58
+ # Check if an environment file contains Python packages we can manage
59
+ sig { params(file: DependencyFile).returns(T::Boolean) }
60
+ def environment_contains_manageable_packages?(file)
61
+ content = file.content
62
+ return false unless content
63
+
64
+ parsed_yaml = begin
65
+ parse_yaml_content(content)
66
+ rescue Psych::SyntaxError => e
67
+ Dependabot.logger.error("YAML parsing error: #{e.message}")
68
+ nil
69
+ end
70
+ return false unless parsed_yaml
71
+
72
+ manageable_conda_packages?(parsed_yaml) || manageable_pip_packages?(parsed_yaml)
73
+ end
74
+
75
+ # Parse YAML content and return parsed hash or nil
76
+ sig { params(content: String).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
77
+ def parse_yaml_content(content)
78
+ require "yaml"
79
+ parsed = YAML.safe_load(content)
80
+ parsed.is_a?(Hash) ? parsed : nil
81
+ end
82
+
83
+ # Check if the parsed YAML contains manageable conda packages
84
+ sig { params(parsed_yaml: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
85
+ def manageable_conda_packages?(parsed_yaml)
86
+ dependencies = parsed_yaml["dependencies"]
87
+ return false unless dependencies.is_a?(Array)
88
+
89
+ simplified_packages = dependencies.select do |dep|
90
+ dep.is_a?(String) && !fully_qualified_spec?(dep) &&
91
+ PythonPackageClassifier.python_package?(PythonPackageClassifier.extract_package_name(dep))
92
+ end
93
+ simplified_packages.any?
94
+ end
95
+
96
+ # Check if the parsed YAML contains manageable pip packages
97
+ sig { params(parsed_yaml: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
98
+ def manageable_pip_packages?(parsed_yaml)
99
+ dependencies = parsed_yaml["dependencies"]
100
+ return false unless dependencies.is_a?(Array)
101
+
102
+ pip_deps = dependencies.find { |dep| dep.is_a?(Hash) && dep.key?("pip") }
103
+ return false unless pip_deps && pip_deps["pip"].is_a?(Array)
104
+
105
+ python_pip_packages = pip_deps["pip"].select do |pip_dep|
106
+ pip_dep.is_a?(String) &&
107
+ PythonPackageClassifier.python_package?(PythonPackageClassifier.extract_package_name(pip_dep))
108
+ end
109
+ python_pip_packages.any?
110
+ end
111
+
112
+ # Check if a package specification is fully qualified (build string included)
113
+ sig { params(spec: String).returns(T::Boolean) }
114
+ def fully_qualified_spec?(spec)
115
+ # Fully qualified specs have format: package=version=build_string
116
+ # e.g., "numpy=1.21.0=py39h20f2e39_0"
117
+ parts = spec.split("=")
118
+ return false unless parts.length >= 3
119
+
120
+ build_string = parts[2]
121
+ return false unless build_string
122
+
123
+ build_string.match?(/^[a-zA-Z0-9_]+$/)
124
+ end
125
+
126
+ sig { returns(String) }
127
+ def unsupported_environment_message
128
+ <<~MSG
129
+ This Conda environment file is not currently supported by Dependabot.
130
+
131
+ Dependabot-Conda supports Python packages only and requires one of the following:
132
+
133
+ 1. **Simplified conda specifications**: Dependencies using simple version syntax (e.g., numpy=1.21.0)
134
+ 2. **Pip section with Python packages**: A 'pip:' section containing Python packages from PyPI
135
+
136
+ **Not supported:**
137
+ - Fully qualified conda specifications (e.g., numpy=1.21.0=py39h20f2e39_0)
138
+ - Non-Python packages (R packages, system tools, etc.)
139
+ - Environments without any Python packages
140
+
141
+ To make your environment compatible:
142
+ - Use simplified conda package specifications for conda packages
143
+ - Add a pip section for PyPI packages
144
+ - Focus on Python packages only
145
+
146
+ For more information, see the Dependabot-Conda documentation.
147
+ MSG
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ Dependabot::FileFetchers.register("conda", Dependabot::Conda::FileFetcher)
@@ -0,0 +1,285 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "sorbet-runtime"
6
+ require "dependabot/file_parsers"
7
+ require "dependabot/file_parsers/base"
8
+ require "dependabot/conda/python_package_classifier"
9
+ require "dependabot/conda/requirement"
10
+ require "dependabot/conda/version"
11
+
12
+ module Dependabot
13
+ module Conda
14
+ class FileParser < Dependabot::FileParsers::Base
15
+ extend T::Sig
16
+
17
+ sig { override.returns(T::Array[Dependabot::Dependency]) }
18
+ def parse
19
+ dependencies = T.let([], T::Array[Dependabot::Dependency])
20
+
21
+ environment_files.each do |file|
22
+ dependencies.concat(parse_environment_file(file))
23
+ end
24
+
25
+ dependencies.uniq
26
+ end
27
+
28
+ private
29
+
30
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
31
+ def environment_files
32
+ dependency_files.select { |f| f.name.match?(/^environment\.ya?ml$/i) }
33
+ end
34
+
35
+ sig { params(file: Dependabot::DependencyFile).returns(T::Array[Dependabot::Dependency]) }
36
+ def parse_environment_file(file)
37
+ dependencies = T.let([], T::Array[Dependabot::Dependency])
38
+
39
+ begin
40
+ content = file.content || ""
41
+ parsed_yaml = YAML.safe_load(content)
42
+ return dependencies unless parsed_yaml.is_a?(Hash)
43
+
44
+ # Parse main dependencies (conda packages)
45
+ if parsed_yaml["dependencies"].is_a?(Array)
46
+ dependencies.concat(parse_conda_dependencies(parsed_yaml["dependencies"], file))
47
+ end
48
+
49
+ # Parse pip dependencies if present
50
+ pip_deps = find_pip_dependencies(parsed_yaml["dependencies"])
51
+ dependencies.concat(parse_pip_dependencies(pip_deps, file)) if pip_deps
52
+ rescue Psych::SyntaxError, Psych::DisallowedClass => e
53
+ raise Dependabot::DependencyFileNotParseable, "Invalid YAML in #{file.name}: #{e.message}"
54
+ end
55
+
56
+ dependencies
57
+ end
58
+
59
+ sig do
60
+ params(dependencies: T::Array[T.untyped],
61
+ file: Dependabot::DependencyFile).returns(T::Array[Dependabot::Dependency])
62
+ end
63
+ def parse_conda_dependencies(dependencies, file)
64
+ parsed_dependencies = T.let([], T::Array[Dependabot::Dependency])
65
+
66
+ # Check if environment has fully qualified packages (Tier 2)
67
+ has_fully_qualified = dependencies.any? do |dep|
68
+ dep.is_a?(String) && fully_qualified_package?(dep)
69
+ end
70
+
71
+ dependencies.each do |dep|
72
+ next unless dep.is_a?(String)
73
+ next if dep.is_a?(Hash) # Skip pip section
74
+
75
+ # Skip conda dependencies if we have fully qualified packages (Tier 2 support)
76
+ next if has_fully_qualified
77
+
78
+ parsed_dep = parse_conda_dependency_string(dep, file)
79
+ next unless parsed_dep
80
+ next unless python_package?(parsed_dep[:name])
81
+ next if parsed_dep[:name] == "pip" # Skip pip itself as it's infrastructure
82
+
83
+ parsed_dependencies << create_dependency(
84
+ name: parsed_dep[:name],
85
+ version: parsed_dep[:version],
86
+ requirements: parsed_dep[:requirements],
87
+ package_manager: "conda"
88
+ )
89
+ end
90
+
91
+ parsed_dependencies
92
+ end
93
+
94
+ sig { params(dependencies: T.nilable(T::Array[T.untyped])).returns(T.nilable(T::Array[String])) }
95
+ def find_pip_dependencies(dependencies)
96
+ return nil unless dependencies.is_a?(Array)
97
+
98
+ pip_section = dependencies.find { |dep| dep.is_a?(Hash) && dep["pip"] }
99
+ return nil unless pip_section
100
+
101
+ pip_deps = pip_section["pip"]
102
+ pip_deps.is_a?(Array) ? pip_deps : nil
103
+ end
104
+
105
+ sig do
106
+ params(pip_deps: T::Array[String], file: Dependabot::DependencyFile).returns(T::Array[Dependabot::Dependency])
107
+ end
108
+ def parse_pip_dependencies(pip_deps, file)
109
+ parsed_dependencies = T.let([], T::Array[Dependabot::Dependency])
110
+
111
+ pip_deps.each do |dep|
112
+ next unless dep.is_a?(String)
113
+
114
+ parsed_dep = parse_pip_dependency_string(dep, file)
115
+ next unless parsed_dep
116
+
117
+ parsed_dependencies << create_dependency(
118
+ name: parsed_dep[:name],
119
+ version: parsed_dep[:version],
120
+ requirements: parsed_dep[:requirements],
121
+ package_manager: "conda"
122
+ )
123
+ end
124
+
125
+ parsed_dependencies
126
+ end
127
+
128
+ sig do
129
+ params(dep_string: String, file: Dependabot::DependencyFile).returns(T.nilable(T::Hash[Symbol, T.untyped]))
130
+ end
131
+ def parse_conda_dependency_string(dep_string, file)
132
+ return nil if dep_string.nil?
133
+
134
+ # Handle channel specifications: conda-forge::numpy=1.21.0
135
+ normalized_dep_string = normalize_conda_dependency_string(dep_string)
136
+ return nil if normalized_dep_string.nil?
137
+
138
+ # Parse conda-style version specifications
139
+ # Examples: numpy=1.21.0, scipy>=1.7.0, pandas, python=3.9, python>=3.8,<3.11
140
+ match = normalized_dep_string.match(/^([a-zA-Z0-9_.-]+)(?:\s*(.+))?$/)
141
+ return nil unless match
142
+
143
+ name = match[1]
144
+ constraint = match[2]&.strip
145
+
146
+ version = extract_conda_version(constraint)
147
+ requirements = build_conda_requirements(constraint, file)
148
+
149
+ {
150
+ name: name,
151
+ version: version,
152
+ requirements: requirements
153
+ }
154
+ end
155
+
156
+ sig { params(dep_string: String).returns(T.nilable(String)) }
157
+ def normalize_conda_dependency_string(dep_string)
158
+ return dep_string unless dep_string.include?("::")
159
+
160
+ parts = dep_string.split("::", 2)
161
+ parts[1]
162
+ end
163
+
164
+ sig { params(constraint: T.nilable(String)).returns(T.nilable(String)) }
165
+ def extract_conda_version(constraint)
166
+ return nil unless constraint
167
+
168
+ case constraint
169
+ when /^=([0-9][a-zA-Z0-9._+-]+)$/
170
+ # Exact conda version: =1.26.0
171
+ constraint[1..-1] # Remove the = prefix
172
+ when /^>=([0-9][a-zA-Z0-9._+-]+)$/
173
+ # Minimum version constraint: >=1.26.0
174
+ # For security purposes, treat this as the current version
175
+ constraint[2..-1] # Remove the >= prefix
176
+ when /^~=([0-9][a-zA-Z0-9._+-]+)$/
177
+ # Compatible release: ~=1.26.0
178
+ constraint[2..-1] # Remove the ~= prefix
179
+ end
180
+ end
181
+
182
+ sig do
183
+ params(constraint: T.nilable(String),
184
+ file: Dependabot::DependencyFile).returns(T::Array[T::Hash[Symbol, T.untyped]])
185
+ end
186
+ def build_conda_requirements(constraint, file)
187
+ return [] unless constraint && !constraint.empty?
188
+
189
+ [{
190
+ requirement: constraint,
191
+ file: file.name,
192
+ source: nil,
193
+ groups: ["dependencies"]
194
+ }]
195
+ end
196
+
197
+ sig do
198
+ params(dep_string: String, file: Dependabot::DependencyFile).returns(T.nilable(T::Hash[Symbol, T.untyped]))
199
+ end
200
+ def parse_pip_dependency_string(dep_string, file)
201
+ # Handle pip-style specifications: requests==2.25.1, flask>=1.0.0
202
+ match = dep_string.match(/^([a-zA-Z0-9_.-]+)(?:\s*(==|>=|>|<=|<|!=|~=)\s*([0-9][a-zA-Z0-9._+-]*))?$/)
203
+ return nil unless match
204
+
205
+ name = match[1]
206
+ operator = match[2]
207
+ version = match[3]
208
+
209
+ # Extract meaningful version information for security update purposes
210
+ extracted_version = nil
211
+ if version
212
+ case operator
213
+ when "==", "="
214
+ # Exact version: use as-is
215
+ extracted_version = version
216
+ when ">=", "~="
217
+ # Minimum version constraint: use the specified version as current
218
+ # This allows security updates to work by treating the constraint as current version
219
+ extracted_version = version
220
+ when ">"
221
+ # Greater than: we can't determine exact version, leave as nil
222
+ extracted_version = nil
223
+ when "<=", "<", "!="
224
+ # Upper bounds or exclusions: not useful for determining current version
225
+ extracted_version = nil
226
+ end
227
+ end
228
+
229
+ requirements = if operator && version
230
+ [{
231
+ requirement: "#{operator}#{version}",
232
+ file: file.name,
233
+ source: nil,
234
+ groups: ["pip"]
235
+ }]
236
+ else
237
+ []
238
+ end
239
+
240
+ {
241
+ name: name,
242
+ version: extracted_version,
243
+ requirements: requirements
244
+ }
245
+ end
246
+
247
+ sig do
248
+ params(
249
+ name: String,
250
+ version: T.nilable(String),
251
+ requirements: T::Array[T::Hash[Symbol, T.untyped]],
252
+ package_manager: String
253
+ ).returns(Dependabot::Dependency)
254
+ end
255
+ def create_dependency(name:, version:, requirements:, package_manager:)
256
+ Dependabot::Dependency.new(
257
+ name: name,
258
+ version: version,
259
+ requirements: requirements,
260
+ package_manager: package_manager
261
+ )
262
+ end
263
+
264
+ sig { params(package_name: String).returns(T::Boolean) }
265
+ def python_package?(package_name)
266
+ PythonPackageClassifier.python_package?(package_name)
267
+ end
268
+
269
+ sig { params(dep_string: String).returns(T::Boolean) }
270
+ def fully_qualified_package?(dep_string)
271
+ # Fully qualified packages have build strings after the version
272
+ # Format: package=version=build_string
273
+ # Example: python=3.9.7=h60c2a47_0_cpython
274
+ dep_string.count("=") >= 2
275
+ end
276
+
277
+ sig { override.returns(T::Boolean) }
278
+ def check_required_files
279
+ dependency_files.any? { |f| f.name.match?(/^environment\.ya?ml$/i) }
280
+ end
281
+ end
282
+ end
283
+ end
284
+
285
+ Dependabot::FileParsers.register("conda", Dependabot::Conda::FileParser)
@@ -0,0 +1,225 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "sorbet-runtime"
6
+ require "dependabot/file_updaters"
7
+ require "dependabot/file_updaters/base"
8
+ require "dependabot/conda/requirement"
9
+
10
+ module Dependabot
11
+ module Conda
12
+ class FileUpdater < Dependabot::FileUpdaters::Base
13
+ extend T::Sig
14
+
15
+ ENVIRONMENT_REGEX = /^environment\.ya?ml$/i
16
+
17
+ # Common version constraint pattern for conda and pip dependencies
18
+ VERSION_CONSTRAINT_PATTERN = '(\s*[=<>!~]=?\s*[^#\s]\S*(?:\s*,\s*[=<>!~]=?\s*[^#\s]\S*)*)?'
19
+
20
+ # Regex patterns for dependency matching
21
+ CONDA_CHANNEL_PATTERN = T.let(lambda do |name|
22
+ /^(\s{2,4}-\s+[a-zA-Z0-9_.-]+::)(#{Regexp.escape(name)})#{VERSION_CONSTRAINT_PATTERN}(\s*)(#.*)?$/
23
+ end, T.proc.params(arg0: T.untyped).returns(Regexp))
24
+
25
+ CONDA_SIMPLE_PATTERN = T.let(lambda do |name|
26
+ /^(\s{2,4}-\s+)(#{Regexp.escape(name)})#{VERSION_CONSTRAINT_PATTERN}(\s*)(#.*)?$/
27
+ end, T.proc.params(arg0: T.untyped).returns(Regexp))
28
+
29
+ PIP_PATTERN = T.let(lambda do |name|
30
+ /^(\s{5,}-\s+)(#{Regexp.escape(name)})#{VERSION_CONSTRAINT_PATTERN}(\s*)(#.*)?$/
31
+ end, T.proc.params(arg0: T.untyped).returns(Regexp))
32
+
33
+ sig { override.returns(T::Array[Regexp]) }
34
+ def self.updated_files_regex
35
+ [ENVIRONMENT_REGEX]
36
+ end
37
+
38
+ sig { override.returns(T::Array[Dependabot::DependencyFile]) }
39
+ def updated_dependency_files
40
+ updated_files = T.let([], T::Array[Dependabot::DependencyFile])
41
+
42
+ environment_files.each do |file|
43
+ updated_file = update_environment_file(file)
44
+ # Always include a file (even if unchanged) to match expected behavior
45
+ updated_files << (updated_file || file)
46
+ end
47
+
48
+ updated_files
49
+ end
50
+
51
+ private
52
+
53
+ sig { override.void }
54
+ def check_required_files
55
+ filenames = dependency_files.map(&:name)
56
+ raise "No environment.yml file found!" unless filenames.any? { |name| name.match?(/^environment\.ya?ml$/i) }
57
+ # File found, all good
58
+ end
59
+
60
+ sig { returns(T::Array[Dependabot::DependencyFile]) }
61
+ def environment_files
62
+ dependency_files.select { |f| f.name.match?(/^environment\.ya?ml$/i) }
63
+ end
64
+
65
+ sig { params(file: Dependabot::DependencyFile).returns(T.nilable(Dependabot::DependencyFile)) }
66
+ def update_environment_file(file)
67
+ content = file.content || ""
68
+ updated_content = T.let(content.dup, String)
69
+
70
+ return nil unless valid_yaml_content?(content, file.name)
71
+
72
+ content_updated = update_dependencies_in_content(updated_content, file.name)
73
+ return nil unless content_updated
74
+
75
+ file.dup.tap { |f| f.content = updated_content }
76
+ end
77
+
78
+ sig { params(content: String, filename: String).returns(T.any(T::Boolean, T.noreturn)) }
79
+ def valid_yaml_content?(content, filename)
80
+ parsed_yaml = YAML.safe_load(content)
81
+ return false unless parsed_yaml.is_a?(Hash) && parsed_yaml["dependencies"].is_a?(Array)
82
+
83
+ true
84
+ rescue Psych::SyntaxError, Psych::DisallowedClass => e
85
+ raise Dependabot::DependencyFileNotParseable, "Invalid YAML in #{filename}: #{e.message}"
86
+ end
87
+
88
+ sig { params(content: String, filename: String).returns(T::Boolean) }
89
+ def update_dependencies_in_content(content, filename)
90
+ content_updated = T.let(false, T::Boolean)
91
+
92
+ dependencies.each do |dependency|
93
+ dependency_updated = update_dependency_in_content(content, dependency)
94
+ if dependency_updated[:updated]
95
+ content.replace(T.cast(dependency_updated[:content], String))
96
+ content_updated = true
97
+ elsif dependency_updated[:not_found]
98
+ handle_dependency_not_found(dependency, filename)
99
+ end
100
+ end
101
+
102
+ content_updated
103
+ end
104
+
105
+ sig { params(dependency: Dependabot::Dependency, filename: String).void }
106
+ def handle_dependency_not_found(dependency, filename)
107
+ return unless dependencies.length == 1
108
+
109
+ raise Dependabot::DependencyFileNotFound,
110
+ "Unable to find dependency #{dependency.name} in #{filename}"
111
+ end
112
+
113
+ sig do
114
+ params(
115
+ content: String,
116
+ dependency: Dependabot::Dependency
117
+ ).returns(T::Hash[Symbol, T.untyped])
118
+ end
119
+ def update_dependency_in_content(content, dependency)
120
+ return { updated: false, content: content, not_found: false } unless dependency.version
121
+
122
+ # Try to update in main conda dependencies section
123
+ conda_result = update_conda_dependency_in_content(content, dependency)
124
+ return conda_result if conda_result[:updated] || conda_result[:not_found]
125
+
126
+ # Try to update in pip dependencies section
127
+ pip_result = update_pip_dependency_in_content(content, dependency)
128
+ return pip_result if pip_result[:updated]
129
+
130
+ # Dependency not found in either section
131
+ { updated: false, content: content, not_found: true }
132
+ end
133
+
134
+ sig do
135
+ params(
136
+ content: String,
137
+ dependency: Dependabot::Dependency
138
+ ).returns(T::Hash[Symbol, T.untyped])
139
+ end
140
+ def update_conda_dependency_in_content(content, dependency)
141
+ # Pattern to match conda dependency lines (with optional channel prefix)
142
+ # Matches: " - numpy=1.26", " - conda-forge::numpy>=1.21.0", " - numpy >= 1.21.0 # comment", etc.
143
+ # Enhanced to handle flexible indentation, space around operators, and comment preservation
144
+ # But restrict to main dependencies section (not deeply nested like pip section)
145
+ conda_patterns = [
146
+ # With channel prefix - main dependencies section (2-4 spaces to avoid pip section)
147
+ CONDA_CHANNEL_PATTERN.call(dependency.name),
148
+ # Without channel prefix - main dependencies section (2-4 spaces to avoid pip section)
149
+ CONDA_SIMPLE_PATTERN.call(dependency.name)
150
+ ]
151
+
152
+ conda_patterns.each do |pattern|
153
+ next unless content.match?(pattern)
154
+
155
+ updated_content = content.gsub(pattern) do
156
+ prefix = ::Regexp.last_match(1)
157
+ name = ::Regexp.last_match(2)
158
+ whitespace_before_comment = ::Regexp.last_match(4) || ""
159
+ comment = ::Regexp.last_match(5) || ""
160
+ # Use the requirement from the dependency object, or default to =version
161
+ new_requirement = get_requirement_for_dependency(dependency, "conda")
162
+ "#{prefix}#{name}#{new_requirement}#{whitespace_before_comment}#{comment}"
163
+ end
164
+ return { updated: true, content: updated_content, not_found: false }
165
+ end
166
+
167
+ { updated: false, content: content, not_found: false }
168
+ end
169
+
170
+ sig do
171
+ params(
172
+ content: String,
173
+ dependency: Dependabot::Dependency
174
+ ).returns(T::Hash[Symbol, T.untyped])
175
+ end
176
+ def update_pip_dependency_in_content(content, dependency)
177
+ # Pattern to match pip dependency lines in pip section
178
+ # Enhanced to handle flexible indentation for pip section (5+ spaces to distinguish from main deps),
179
+ # better operator support, and multiple constraints like "requests>=2.25.0,<3.0"
180
+ # Capture whitespace between requirement and comment to preserve formatting
181
+ pip_pattern = PIP_PATTERN.call(dependency.name)
182
+
183
+ if content.match?(pip_pattern)
184
+ updated_content = content.gsub(pip_pattern) do
185
+ prefix = ::Regexp.last_match(1)
186
+ name = ::Regexp.last_match(2)
187
+ whitespace_before_comment = ::Regexp.last_match(4) || ""
188
+ comment = ::Regexp.last_match(5) || ""
189
+ # Use the requirement from the dependency object, or default to ==version
190
+ new_requirement = get_requirement_for_dependency(dependency, "pip")
191
+ "#{prefix}#{name}#{new_requirement}#{whitespace_before_comment}#{comment}"
192
+ end
193
+ return { updated: true, content: updated_content, not_found: false }
194
+ end
195
+
196
+ { updated: false, content: content, not_found: false }
197
+ end
198
+
199
+ sig do
200
+ params(
201
+ dependency: Dependabot::Dependency,
202
+ context: String
203
+ ).returns(String)
204
+ end
205
+ def get_requirement_for_dependency(dependency, context)
206
+ # Look for a requirement in the dependency's requirements array
207
+ requirements = dependency.requirements
208
+ requirement = nil
209
+
210
+ requirement = requirements.first&.dig(:requirement) unless requirements.empty?
211
+
212
+ return requirement if requirement && !requirement.empty?
213
+
214
+ # Fallback to default format based on context
215
+ if context == "pip"
216
+ "==#{dependency.version}"
217
+ else
218
+ "=#{dependency.version}"
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+
225
+ Dependabot::FileUpdaters.register("conda", Dependabot::Conda::FileUpdater)