dependabot-conda 0.349.0 → 0.350.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 +4 -4
- data/lib/dependabot/conda/conda_registry_client.rb +220 -0
- data/lib/dependabot/conda/file_fetcher.rb +115 -66
- data/lib/dependabot/conda/file_parser.rb +40 -38
- data/lib/dependabot/conda/file_updater.rb +40 -16
- data/lib/dependabot/conda/metadata_finder.rb +8 -15
- data/lib/dependabot/conda/update_checker/latest_version_finder.rb +111 -6
- data/lib/dependabot/conda/update_checker/requirements_updater.rb +428 -0
- data/lib/dependabot/conda/update_checker.rb +16 -63
- data/lib/dependabot/conda/version.rb +317 -6
- data/lib/dependabot/conda.rb +4 -3
- metadata +11 -10
- data/lib/dependabot/conda/python_package_classifier.rb +0 -88
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce8cbd205c7b0ab1f739bfa46499f627230e6fe1628a7da150b136dcd7302658
|
|
4
|
+
data.tar.gz: feaa389e0e1332309d6bba5b583bb0933bcdb389348810b77200c6d62e279c65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aac66701b1fa4423be74d27af6a806bee85103145a07ce076d1b00238858b696cb62a6ca9b6bcb299100e936cda55d12d3477b4ce7927eaa76675e88be1d2ab8
|
|
7
|
+
data.tar.gz: 2480678c876f4a278fa313f0687f6b0d6cd6d8823f3e9bcb71fc422c98d8eb97240fba624b2a0c12de7df1977fee0c6cf7a4cd42ec24271b9e5115d75c764aaa
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "excon"
|
|
5
|
+
require "json"
|
|
6
|
+
require "sorbet-runtime"
|
|
7
|
+
require "dependabot/conda/version"
|
|
8
|
+
require "dependabot/registry_client"
|
|
9
|
+
require "dependabot/shared_helpers"
|
|
10
|
+
|
|
11
|
+
module Dependabot
|
|
12
|
+
module Conda
|
|
13
|
+
class CondaRegistryClient
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
# Supported public conda channels (user-facing names from environment.yml)
|
|
17
|
+
SUPPORTED_CHANNELS = T.let(
|
|
18
|
+
%w(anaconda conda-forge defaults bioconda main).freeze,
|
|
19
|
+
T::Array[String]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Channel aliases: maps user-facing channel names to API channel names
|
|
23
|
+
# 'defaults' is a Conda client alias that doesn't exist on anaconda.org API
|
|
24
|
+
CHANNEL_ALIASES = T.let(
|
|
25
|
+
{ "defaults" => "anaconda" }.freeze,
|
|
26
|
+
T::Hash[String, String]
|
|
27
|
+
)
|
|
28
|
+
# anaconda.org API configuration
|
|
29
|
+
DEFAULT_CHANNEL = T.let("anaconda", String)
|
|
30
|
+
API_BASE_URL = T.let("https://api.anaconda.org", String)
|
|
31
|
+
CONNECTION_TIMEOUT = T.let(5, Integer)
|
|
32
|
+
READ_TIMEOUT = T.let(10, Integer)
|
|
33
|
+
MAX_RETRIES = T.let(1, Integer)
|
|
34
|
+
|
|
35
|
+
sig { void }
|
|
36
|
+
def initialize
|
|
37
|
+
@cache = T.let({}, T::Hash[String, T.untyped])
|
|
38
|
+
@not_found_cache = T.let(Set.new, T::Set[String])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fetch package metadata from Conda API
|
|
42
|
+
sig do
|
|
43
|
+
params(
|
|
44
|
+
package_name: String,
|
|
45
|
+
channel: String
|
|
46
|
+
).returns(T.nilable(T::Hash[String, T.untyped]))
|
|
47
|
+
end
|
|
48
|
+
def fetch_package_metadata(package_name, channel = DEFAULT_CHANNEL)
|
|
49
|
+
cache_key = "#{channel}/#{package_name}"
|
|
50
|
+
|
|
51
|
+
# Check 404 cache first
|
|
52
|
+
return nil if @not_found_cache.include?(cache_key)
|
|
53
|
+
|
|
54
|
+
# Check cache
|
|
55
|
+
return @cache[cache_key] if @cache.key?(cache_key)
|
|
56
|
+
|
|
57
|
+
# Fetch from API
|
|
58
|
+
fetch_from_api(package_name, channel, cache_key)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check if a specific version exists for a package
|
|
62
|
+
sig do
|
|
63
|
+
params(
|
|
64
|
+
package_name: String,
|
|
65
|
+
version: String,
|
|
66
|
+
channel: String
|
|
67
|
+
).returns(T::Boolean)
|
|
68
|
+
end
|
|
69
|
+
def version_exists?(package_name, version, channel = DEFAULT_CHANNEL)
|
|
70
|
+
metadata = fetch_package_metadata(package_name, channel)
|
|
71
|
+
return false unless metadata
|
|
72
|
+
|
|
73
|
+
versions = metadata["versions"]
|
|
74
|
+
return false unless versions.is_a?(Array)
|
|
75
|
+
|
|
76
|
+
versions.include?(version)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get all available versions for a package, sorted newest first
|
|
80
|
+
sig do
|
|
81
|
+
params(
|
|
82
|
+
package_name: String,
|
|
83
|
+
channel: String
|
|
84
|
+
).returns(T::Array[Dependabot::Conda::Version])
|
|
85
|
+
end
|
|
86
|
+
def available_versions(package_name, channel = DEFAULT_CHANNEL)
|
|
87
|
+
metadata = fetch_package_metadata(package_name, channel)
|
|
88
|
+
return [] unless metadata
|
|
89
|
+
|
|
90
|
+
versions = metadata["versions"]
|
|
91
|
+
return [] unless versions.is_a?(Array)
|
|
92
|
+
|
|
93
|
+
# Parse and sort versions
|
|
94
|
+
parsed_versions = versions.filter_map do |version_string|
|
|
95
|
+
Dependabot::Conda::Version.new(version_string)
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
# Invalid version format - skip it
|
|
98
|
+
Dependabot.logger.debug("Skipping invalid conda version: #{version_string}")
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Sort newest first
|
|
103
|
+
parsed_versions.sort.reverse
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get the latest version for a package
|
|
107
|
+
sig do
|
|
108
|
+
params(
|
|
109
|
+
package_name: String,
|
|
110
|
+
channel: String
|
|
111
|
+
).returns(T.nilable(Dependabot::Conda::Version))
|
|
112
|
+
end
|
|
113
|
+
def latest_version(package_name, channel = DEFAULT_CHANNEL)
|
|
114
|
+
versions = available_versions(package_name, channel)
|
|
115
|
+
versions.first
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get package metadata fields for MetadataFinder
|
|
119
|
+
sig do
|
|
120
|
+
params(
|
|
121
|
+
package_name: String,
|
|
122
|
+
channel: String
|
|
123
|
+
).returns(T.nilable(T::Hash[Symbol, T.nilable(String)]))
|
|
124
|
+
end
|
|
125
|
+
def package_metadata(package_name, channel = DEFAULT_CHANNEL)
|
|
126
|
+
metadata = fetch_package_metadata(package_name, channel)
|
|
127
|
+
return nil unless metadata
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
homepage: metadata["home"],
|
|
131
|
+
source_url: metadata["dev_url"],
|
|
132
|
+
description: metadata["summary"],
|
|
133
|
+
license: metadata["license"]
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Normalize channel name from user-facing to API-compatible
|
|
140
|
+
sig { params(channel: String).returns(String) }
|
|
141
|
+
def normalize_channel(channel)
|
|
142
|
+
CHANNEL_ALIASES[channel] || channel
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sig do
|
|
146
|
+
params(
|
|
147
|
+
package_name: String,
|
|
148
|
+
channel: String,
|
|
149
|
+
cache_key: String
|
|
150
|
+
).returns(T.nilable(T::Hash[String, T.untyped]))
|
|
151
|
+
end
|
|
152
|
+
def fetch_from_api(package_name, channel, cache_key)
|
|
153
|
+
# Normalize channel name for API (e.g., 'defaults' -> 'anaconda')
|
|
154
|
+
api_channel = normalize_channel(channel)
|
|
155
|
+
url = "#{API_BASE_URL}/package/#{api_channel}/#{package_name}"
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
response = make_http_request(url)
|
|
159
|
+
handle_response(response, package_name, cache_key)
|
|
160
|
+
rescue JSON::ParserError => e
|
|
161
|
+
Dependabot.logger.error("Invalid JSON from Conda API for #{package_name}: #{e.message}")
|
|
162
|
+
nil
|
|
163
|
+
rescue Excon::Error::Socket, Excon::Error::Timeout => e
|
|
164
|
+
Dependabot.logger.error("Conda API connection error for #{package_name}: #{e.message}")
|
|
165
|
+
raise Dependabot::DependabotError, "Failed to connect to Conda API: #{e.message}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
sig { params(url: String).returns(Excon::Response) }
|
|
170
|
+
def make_http_request(url)
|
|
171
|
+
Dependabot::RegistryClient.get(
|
|
172
|
+
url: url,
|
|
173
|
+
headers: {
|
|
174
|
+
"Accept" => "application/json",
|
|
175
|
+
"User-Agent" => Dependabot::SharedHelpers::USER_AGENT
|
|
176
|
+
},
|
|
177
|
+
options: {
|
|
178
|
+
connect_timeout: CONNECTION_TIMEOUT,
|
|
179
|
+
read_timeout: READ_TIMEOUT,
|
|
180
|
+
retry_limit: MAX_RETRIES
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
sig do
|
|
186
|
+
params(
|
|
187
|
+
response: Excon::Response,
|
|
188
|
+
package_name: String,
|
|
189
|
+
cache_key: String
|
|
190
|
+
).returns(T.nilable(T::Hash[String, T.untyped]))
|
|
191
|
+
end
|
|
192
|
+
def handle_response(response, package_name, cache_key)
|
|
193
|
+
case response.status
|
|
194
|
+
when 200
|
|
195
|
+
data = JSON.parse(response.body)
|
|
196
|
+
@cache[cache_key] = data
|
|
197
|
+
data
|
|
198
|
+
when 404
|
|
199
|
+
@not_found_cache.add(cache_key)
|
|
200
|
+
nil
|
|
201
|
+
when 429
|
|
202
|
+
handle_rate_limit(response, package_name)
|
|
203
|
+
else
|
|
204
|
+
Dependabot.logger.error("Unexpected Conda API response: #{response.status} for #{package_name}")
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
sig { params(response: Excon::Response, package_name: String).returns(T.noreturn) }
|
|
210
|
+
def handle_rate_limit(response, package_name)
|
|
211
|
+
retry_after = response.headers["Retry-After"]&.to_i || 60
|
|
212
|
+
Dependabot.logger.warn(
|
|
213
|
+
"Conda API rate limited. Retry after #{retry_after} seconds. Package: #{package_name}"
|
|
214
|
+
)
|
|
215
|
+
raise Dependabot::DependabotError,
|
|
216
|
+
"Conda API rate limited. Please try again in #{retry_after} seconds."
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "yaml"
|
|
4
5
|
require "sorbet-runtime"
|
|
5
6
|
require "dependabot/file_fetchers"
|
|
6
7
|
require "dependabot/file_fetchers/base"
|
|
7
|
-
require "dependabot/conda/python_package_classifier"
|
|
8
8
|
|
|
9
9
|
module Dependabot
|
|
10
10
|
module Conda
|
|
@@ -31,101 +31,150 @@ module Dependabot
|
|
|
31
31
|
|
|
32
32
|
sig { override.returns(T::Array[DependencyFile]) }
|
|
33
33
|
def fetch_files
|
|
34
|
-
|
|
34
|
+
unless allow_beta_ecosystems?
|
|
35
|
+
raise Dependabot::DependencyFileNotFound.new(
|
|
36
|
+
nil,
|
|
37
|
+
"Conda support is currently in beta. Set ALLOW_BETA_ECOSYSTEMS=true to enable it."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
fetched_files = []
|
|
42
|
+
|
|
43
|
+
# Try to fetch environment.yml first, then environment.yaml
|
|
44
|
+
environment_file = fetch_file_if_present("environment.yml") ||
|
|
45
|
+
fetch_file_if_present("environment.yaml")
|
|
46
|
+
|
|
47
|
+
if environment_file
|
|
48
|
+
# Validate it's a proper conda environment file
|
|
49
|
+
validation = validate_conda_environment(environment_file)
|
|
50
|
+
unless validation[:valid]
|
|
51
|
+
raise(
|
|
52
|
+
Dependabot::DependencyFileNotFound.new(
|
|
53
|
+
File.join(directory, environment_file.name),
|
|
54
|
+
unsupported_environment_message(validation[:reason])
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
fetched_files << environment_file
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return fetched_files if fetched_files.any?
|
|
62
|
+
|
|
63
|
+
raise(
|
|
64
|
+
Dependabot::DependencyFileNotFound,
|
|
65
|
+
File.join(directory, "environment.yml")
|
|
66
|
+
)
|
|
35
67
|
end
|
|
36
68
|
|
|
37
69
|
private
|
|
38
70
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
71
|
+
# Validate that environment file is a proper conda manifest with manageable packages
|
|
72
|
+
# Returns a hash with :valid (Boolean) and :reason (Symbol or nil)
|
|
73
|
+
sig { params(file: DependencyFile).returns(T::Hash[Symbol, T.untyped]) }
|
|
74
|
+
def validate_conda_environment(file)
|
|
42
75
|
content = file.content
|
|
43
|
-
return false unless content
|
|
76
|
+
return { valid: false, reason: :no_content } unless content
|
|
44
77
|
|
|
45
|
-
parsed_yaml =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return false unless parsed_yaml
|
|
78
|
+
parsed_yaml = parse_and_validate_yaml(content)
|
|
79
|
+
return { valid: false, reason: :invalid_yaml } unless parsed_yaml
|
|
80
|
+
|
|
81
|
+
dependencies = parsed_yaml["dependencies"]
|
|
82
|
+
return { valid: false, reason: :no_dependencies } unless dependencies.is_a?(Array)
|
|
83
|
+
return { valid: false, reason: :empty_dependencies } if dependencies.empty?
|
|
52
84
|
|
|
53
|
-
|
|
85
|
+
# Check if all packages are fully qualified (no manageable packages)
|
|
86
|
+
return { valid: false, reason: :all_fully_qualified } unless manageable_packages?(dependencies)
|
|
87
|
+
|
|
88
|
+
{ valid: true, reason: nil }
|
|
54
89
|
end
|
|
55
90
|
|
|
56
|
-
# Parse YAML content and return parsed hash or nil
|
|
57
91
|
sig { params(content: String).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
92
|
+
def parse_and_validate_yaml(content)
|
|
93
|
+
parsed_yaml = parse_yaml_content(content)
|
|
94
|
+
return nil unless parsed_yaml
|
|
95
|
+
return nil unless parsed_yaml.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
parsed_yaml
|
|
98
|
+
rescue Psych::SyntaxError => e
|
|
99
|
+
Dependabot.logger.error("YAML parsing error: #{e.message}")
|
|
100
|
+
nil
|
|
62
101
|
end
|
|
63
102
|
|
|
64
|
-
# Check if
|
|
65
|
-
sig { params(
|
|
66
|
-
def
|
|
67
|
-
dependencies = parsed_yaml["dependencies"]
|
|
103
|
+
# Check if there are any manageable packages (simple specs or pip)
|
|
104
|
+
sig { params(dependencies: T.untyped).returns(T::Boolean) }
|
|
105
|
+
def manageable_packages?(dependencies)
|
|
68
106
|
return false unless dependencies.is_a?(Array)
|
|
69
107
|
|
|
70
|
-
|
|
71
|
-
dep.is_a?(String) && !fully_qualified_spec?(dep)
|
|
72
|
-
PythonPackageClassifier.python_package?(PythonPackageClassifier.extract_package_name(dep))
|
|
108
|
+
has_simple_conda = dependencies.any? do |dep|
|
|
109
|
+
dep.is_a?(String) && !fully_qualified_spec?(dep)
|
|
73
110
|
end
|
|
74
|
-
simplified_packages.any?
|
|
75
|
-
end
|
|
76
111
|
|
|
77
|
-
|
|
78
|
-
sig { params(parsed_yaml: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
|
|
79
|
-
def manageable_pip_packages?(parsed_yaml)
|
|
80
|
-
dependencies = parsed_yaml["dependencies"]
|
|
81
|
-
return false unless dependencies.is_a?(Array)
|
|
112
|
+
has_pip = dependencies.any? { |dep| dep.is_a?(Hash) && dep.key?("pip") }
|
|
82
113
|
|
|
83
|
-
|
|
84
|
-
|
|
114
|
+
has_simple_conda || has_pip
|
|
115
|
+
end
|
|
85
116
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
python_pip_packages.any?
|
|
117
|
+
sig { params(content: String).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
|
|
118
|
+
def parse_yaml_content(content)
|
|
119
|
+
parsed = YAML.safe_load(content)
|
|
120
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
91
121
|
end
|
|
92
122
|
|
|
93
|
-
# Check if a package specification is fully qualified (build string included)
|
|
94
123
|
sig { params(spec: String).returns(T::Boolean) }
|
|
95
124
|
def fully_qualified_spec?(spec)
|
|
96
|
-
# Fully qualified
|
|
97
|
-
|
|
125
|
+
# Fully qualified: package=version=build_string (e.g., numpy=1.21.0=py39h20f2e39_0)
|
|
126
|
+
return false if spec.include?("==")
|
|
127
|
+
return false if spec.include?("[")
|
|
128
|
+
|
|
98
129
|
parts = spec.split("=")
|
|
99
130
|
return false unless parts.length >= 3
|
|
100
131
|
|
|
101
132
|
build_string = parts[2]
|
|
102
|
-
return false unless build_string
|
|
133
|
+
return false unless build_string && !build_string.empty?
|
|
103
134
|
|
|
104
135
|
build_string.match?(/^[a-zA-Z0-9_]+$/)
|
|
105
136
|
end
|
|
106
137
|
|
|
107
|
-
sig { returns(String) }
|
|
108
|
-
def unsupported_environment_message
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
sig { params(reason: T.nilable(Symbol)).returns(String) }
|
|
139
|
+
def unsupported_environment_message(reason)
|
|
140
|
+
case reason
|
|
141
|
+
when :all_fully_qualified
|
|
142
|
+
<<~MSG
|
|
143
|
+
This environment file contains only fully qualified package specifications with build strings.
|
|
144
|
+
|
|
145
|
+
Dependabot cannot update packages with build strings like:
|
|
146
|
+
- numpy=1.21.0=py39h20f2e39_0
|
|
147
|
+
|
|
148
|
+
To fix, remove the build string. Dependabot supports simplified specifications \
|
|
149
|
+
(e.g., numpy=1.21.0,r-base>=4.0)
|
|
150
|
+
MSG
|
|
151
|
+
when :no_dependencies, :empty_dependencies
|
|
152
|
+
<<~MSG
|
|
153
|
+
This environment file has no dependencies to manage.
|
|
154
|
+
|
|
155
|
+
Add at least one package to the dependencies section:
|
|
156
|
+
dependencies:
|
|
157
|
+
- python>=3.9
|
|
158
|
+
- numpy>=1.21.0
|
|
159
|
+
MSG
|
|
160
|
+
when :invalid_yaml
|
|
161
|
+
<<~MSG
|
|
162
|
+
This environment file contains invalid YAML syntax.
|
|
163
|
+
|
|
164
|
+
Please fix the YAML syntax errors before Dependabot can process this file.
|
|
165
|
+
MSG
|
|
166
|
+
else
|
|
167
|
+
<<~MSG
|
|
168
|
+
This Conda environment file is not supported by Dependabot.
|
|
169
|
+
|
|
170
|
+
Dependabot supports:
|
|
171
|
+
- Simplified conda specifications (e.g., numpy=1.21.0, r-base>=4.0)
|
|
172
|
+
- Pip dependencies in the pip section
|
|
173
|
+
|
|
174
|
+
Not supported:
|
|
175
|
+
- Fully qualified specifications with build strings (e.g., numpy=1.21.0=py39h20f2e39_0)
|
|
176
|
+
MSG
|
|
177
|
+
end
|
|
129
178
|
end
|
|
130
179
|
end
|
|
131
180
|
end
|
|
@@ -5,10 +5,10 @@ require "yaml"
|
|
|
5
5
|
require "sorbet-runtime"
|
|
6
6
|
require "dependabot/file_parsers"
|
|
7
7
|
require "dependabot/file_parsers/base"
|
|
8
|
-
require "dependabot/conda/python_package_classifier"
|
|
9
8
|
require "dependabot/conda/requirement"
|
|
10
9
|
require "dependabot/conda/version"
|
|
11
10
|
require "dependabot/conda/package_manager"
|
|
11
|
+
require "dependabot/conda/conda_registry_client"
|
|
12
12
|
|
|
13
13
|
module Dependabot
|
|
14
14
|
module Conda
|
|
@@ -93,15 +93,12 @@ module Dependabot
|
|
|
93
93
|
|
|
94
94
|
dependencies.each do |dep|
|
|
95
95
|
next unless dep.is_a?(String)
|
|
96
|
-
next if dep.is_a?(Hash)
|
|
97
|
-
|
|
98
|
-
# Skip conda dependencies if we have fully qualified packages (Tier 2 support)
|
|
96
|
+
next if dep.is_a?(Hash)
|
|
99
97
|
next if has_fully_qualified
|
|
100
98
|
|
|
101
99
|
parsed_dep = parse_conda_dependency_string(dep, file)
|
|
102
100
|
next unless parsed_dep
|
|
103
|
-
next
|
|
104
|
-
next if parsed_dep[:name] == "pip" # Skip pip itself as it's infrastructure
|
|
101
|
+
next if parsed_dep[:name] == "pip"
|
|
105
102
|
|
|
106
103
|
parsed_dependencies << create_dependency(
|
|
107
104
|
name: parsed_dep[:name],
|
|
@@ -114,7 +111,7 @@ module Dependabot
|
|
|
114
111
|
parsed_dependencies
|
|
115
112
|
end
|
|
116
113
|
|
|
117
|
-
sig { params(dependencies: T.
|
|
114
|
+
sig { params(dependencies: T.untyped).returns(T.nilable(T::Array[String])) }
|
|
118
115
|
def find_pip_dependencies(dependencies)
|
|
119
116
|
return nil unless dependencies.is_a?(Array)
|
|
120
117
|
|
|
@@ -141,7 +138,7 @@ module Dependabot
|
|
|
141
138
|
name: parsed_dep[:name],
|
|
142
139
|
version: parsed_dep[:version],
|
|
143
140
|
requirements: parsed_dep[:requirements],
|
|
144
|
-
package_manager: "
|
|
141
|
+
package_manager: "pip"
|
|
145
142
|
)
|
|
146
143
|
end
|
|
147
144
|
|
|
@@ -154,12 +151,18 @@ module Dependabot
|
|
|
154
151
|
def parse_conda_dependency_string(dep_string, file)
|
|
155
152
|
return nil if dep_string.nil?
|
|
156
153
|
|
|
154
|
+
# Extract channel prefix before normalizing (e.g., "conda-forge::numpy=1.26.0")
|
|
155
|
+
channel = extract_channel_from_dependency_string(dep_string)
|
|
156
|
+
|
|
157
157
|
# Handle channel specifications: conda-forge::numpy=1.21.0
|
|
158
158
|
normalized_dep_string = normalize_conda_dependency_string(dep_string)
|
|
159
159
|
return nil if normalized_dep_string.nil?
|
|
160
160
|
|
|
161
|
-
#
|
|
162
|
-
|
|
161
|
+
# Handle bracket syntax: package[version='>=1.0']
|
|
162
|
+
if normalized_dep_string.include?("[")
|
|
163
|
+
bracket_match = normalized_dep_string.match(/^([a-zA-Z0-9_.-]+)\[version=['"](.+)['"]\]$/)
|
|
164
|
+
normalized_dep_string = "#{bracket_match[1]}#{bracket_match[2]}" if bracket_match
|
|
165
|
+
end
|
|
163
166
|
match = normalized_dep_string.match(/^([a-zA-Z0-9_.-]+)(?:\s*(.+))?$/)
|
|
164
167
|
return nil unless match
|
|
165
168
|
|
|
@@ -167,7 +170,7 @@ module Dependabot
|
|
|
167
170
|
constraint = match[2]&.strip
|
|
168
171
|
|
|
169
172
|
version = extract_conda_version(constraint)
|
|
170
|
-
requirements = build_conda_requirements(constraint, file)
|
|
173
|
+
requirements = build_conda_requirements(constraint, file, channel)
|
|
171
174
|
|
|
172
175
|
{
|
|
173
176
|
name: name,
|
|
@@ -176,6 +179,17 @@ module Dependabot
|
|
|
176
179
|
}
|
|
177
180
|
end
|
|
178
181
|
|
|
182
|
+
sig { params(dep_string: String).returns(T.nilable(String)) }
|
|
183
|
+
def extract_channel_from_dependency_string(dep_string)
|
|
184
|
+
return nil unless dep_string.include?("::")
|
|
185
|
+
|
|
186
|
+
channel = dep_string.split("::", 2).first
|
|
187
|
+
return nil unless channel
|
|
188
|
+
return nil unless CondaRegistryClient::SUPPORTED_CHANNELS.include?(channel)
|
|
189
|
+
|
|
190
|
+
channel
|
|
191
|
+
end
|
|
192
|
+
|
|
179
193
|
sig { params(dep_string: String).returns(T.nilable(String)) }
|
|
180
194
|
def normalize_conda_dependency_string(dep_string)
|
|
181
195
|
return dep_string unless dep_string.include?("::")
|
|
@@ -189,32 +203,31 @@ module Dependabot
|
|
|
189
203
|
return nil unless constraint
|
|
190
204
|
|
|
191
205
|
case constraint
|
|
206
|
+
when /^==([0-9][a-zA-Z0-9._+-]+)$/
|
|
207
|
+
constraint[2..-1]
|
|
192
208
|
when /^=([0-9][a-zA-Z0-9._+-]+)$/
|
|
193
|
-
|
|
194
|
-
constraint[1..-1] # Remove the = prefix
|
|
209
|
+
constraint[1..-1]
|
|
195
210
|
when /^>=([0-9][a-zA-Z0-9._+-]+)$/
|
|
196
|
-
|
|
197
|
-
# For security purposes, treat this as the current version
|
|
198
|
-
constraint[2..-1] # Remove the >= prefix
|
|
211
|
+
constraint[2..-1]
|
|
199
212
|
when /^~=([0-9][a-zA-Z0-9._+-]+)$/
|
|
200
|
-
|
|
201
|
-
constraint[2..-1] # Remove the ~= prefix
|
|
213
|
+
constraint[2..-1]
|
|
202
214
|
end
|
|
203
215
|
end
|
|
204
216
|
|
|
205
217
|
sig do
|
|
206
218
|
params(
|
|
207
219
|
constraint: T.nilable(String),
|
|
208
|
-
file: Dependabot::DependencyFile
|
|
220
|
+
file: Dependabot::DependencyFile,
|
|
221
|
+
channel: T.nilable(String)
|
|
209
222
|
).returns(T::Array[T::Hash[Symbol, T.untyped]])
|
|
210
223
|
end
|
|
211
|
-
def build_conda_requirements(constraint, file)
|
|
212
|
-
|
|
224
|
+
def build_conda_requirements(constraint, file, channel = nil)
|
|
225
|
+
source = channel ? { channel: channel } : nil
|
|
213
226
|
|
|
214
227
|
[{
|
|
215
|
-
requirement: constraint,
|
|
228
|
+
requirement: constraint && !constraint.empty? ? constraint : nil,
|
|
216
229
|
file: file.name,
|
|
217
|
-
source:
|
|
230
|
+
source: source,
|
|
218
231
|
groups: ["dependencies"]
|
|
219
232
|
}]
|
|
220
233
|
end
|
|
@@ -223,7 +236,6 @@ module Dependabot
|
|
|
223
236
|
params(dep_string: String, file: Dependabot::DependencyFile).returns(T.nilable(T::Hash[Symbol, T.untyped]))
|
|
224
237
|
end
|
|
225
238
|
def parse_pip_dependency_string(dep_string, file)
|
|
226
|
-
# Handle pip-style specifications: requests==2.25.1, flask>=1.0.0
|
|
227
239
|
match = dep_string.match(/^([a-zA-Z0-9_.-]+)(?:\s*(==|>=|>|<=|<|!=|~=)\s*([0-9][a-zA-Z0-9._+-]*))?$/)
|
|
228
240
|
return nil unless match
|
|
229
241
|
|
|
@@ -231,22 +243,16 @@ module Dependabot
|
|
|
231
243
|
operator = match[2]
|
|
232
244
|
version = match[3]
|
|
233
245
|
|
|
234
|
-
# Extract meaningful version information for security update purposes
|
|
235
246
|
extracted_version = nil
|
|
236
247
|
if version
|
|
237
248
|
case operator
|
|
238
249
|
when "==", "="
|
|
239
|
-
# Exact version: use as-is
|
|
240
250
|
extracted_version = version
|
|
241
251
|
when ">=", "~="
|
|
242
|
-
# Minimum version constraint: use the specified version as current
|
|
243
|
-
# This allows security updates to work by treating the constraint as current version
|
|
244
252
|
extracted_version = version
|
|
245
253
|
when ">"
|
|
246
|
-
# Greater than: we can't determine exact version, leave as nil
|
|
247
254
|
extracted_version = nil
|
|
248
255
|
when "<=", "<", "!="
|
|
249
|
-
# Upper bounds or exclusions: not useful for determining current version
|
|
250
256
|
extracted_version = nil
|
|
251
257
|
end
|
|
252
258
|
end
|
|
@@ -286,16 +292,12 @@ module Dependabot
|
|
|
286
292
|
)
|
|
287
293
|
end
|
|
288
294
|
|
|
289
|
-
sig { params(package_name: String).returns(T::Boolean) }
|
|
290
|
-
def python_package?(package_name)
|
|
291
|
-
PythonPackageClassifier.python_package?(package_name)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
295
|
sig { params(dep_string: String).returns(T::Boolean) }
|
|
295
296
|
def fully_qualified_package?(dep_string)
|
|
296
|
-
# Fully qualified
|
|
297
|
-
|
|
298
|
-
|
|
297
|
+
# Fully qualified: package=version=build_string (e.g., python=3.9.7=h60c2a47_0)
|
|
298
|
+
return false if dep_string.include?("==")
|
|
299
|
+
return false if dep_string.include?("[")
|
|
300
|
+
|
|
299
301
|
dep_string.count("=") >= 2
|
|
300
302
|
end
|
|
301
303
|
|