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 +7 -0
- data/lib/dependabot/conda/file_fetcher.rb +153 -0
- data/lib/dependabot/conda/file_parser.rb +285 -0
- data/lib/dependabot/conda/file_updater.rb +225 -0
- data/lib/dependabot/conda/metadata_finder.rb +71 -0
- data/lib/dependabot/conda/name_normaliser.rb +19 -0
- data/lib/dependabot/conda/package_manager.rb +45 -0
- data/lib/dependabot/conda/python_package_classifier.rb +85 -0
- data/lib/dependabot/conda/requirement.rb +133 -0
- data/lib/dependabot/conda/update_checker/latest_version_finder.rb +91 -0
- data/lib/dependabot/conda/update_checker/requirement_translator.rb +57 -0
- data/lib/dependabot/conda/update_checker.rb +196 -0
- data/lib/dependabot/conda/version.rb +23 -0
- data/lib/dependabot/conda.rb +35 -0
- metadata +294 -0
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)
|