dependabot-conda 0.348.1 → 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.
@@ -1,21 +1,332 @@
1
- # typed: strong
1
+ # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "sorbet-runtime"
5
- require "dependabot/python/version"
5
+ require "dependabot/version"
6
+ require "dependabot/utils"
6
7
 
7
8
  module Dependabot
8
9
  module Conda
9
- # Conda version handling delegates to Python version since conda primarily manages Python packages
10
- class Version < Dependabot::Python::Version
10
+ # Conda version handling based on conda's version specification
11
+ # See: https://docs.conda.io/projects/conda/en/stable/user-guide/concepts/pkg-specs.html
12
+ #
13
+ # Version format: [epoch!]version[+local]
14
+ #
15
+ # Components:
16
+ # - Epoch (optional): Integer prefix with ! separator (e.g., "1!2.0")
17
+ # - Version: Main version identifier with segments separated by . or _
18
+ # - Local version (optional): Build metadata with + prefix (e.g., "1.0+abc.7")
19
+ #
20
+ # Special handling:
21
+ # - "dev" pre-releases sort before all other pre-releases
22
+ # - "post" releases sort after the main version
23
+ # - Underscores are normalized to dots
24
+ # - Case-insensitive string comparison
25
+ # - Fillvalue 0 insertion for missing segments
26
+ # - Integer < String in mixed-type segment comparison (numeric before pre-release)
27
+ class Version < Dependabot::Version
11
28
  extend T::Sig
12
29
 
13
- # Conda supports the same version formats as Python packages from PyPI
14
- # This includes standard semver, epochs, pre-releases, dev releases, etc.
30
+ VERSION_PATTERN = /\A[a-z0-9_.!+\-]+\z/i
31
+
32
+ sig { override.params(version: VersionParameter).returns(T::Boolean) }
33
+ def self.correct?(version)
34
+ return false if version.nil? || version.to_s.empty?
35
+
36
+ version.to_s.match?(VERSION_PATTERN)
37
+ end
38
+
15
39
  sig { override.params(version: VersionParameter).returns(Dependabot::Conda::Version) }
16
40
  def self.new(version)
17
41
  T.cast(super, Dependabot::Conda::Version)
18
42
  end
43
+
44
+ sig { returns(Integer) }
45
+ attr_reader :epoch
46
+
47
+ sig { returns(T::Array[T.any(Integer, String)]) }
48
+ attr_reader :version_parts
49
+
50
+ sig { returns(T.nilable(T::Array[T.any(Integer, String)])) }
51
+ attr_reader :local_parts
52
+
53
+ VersionParts = T.let(Struct.new(:epoch, :main, :local, keyword_init: true), T.untyped)
54
+ private_constant :VersionParts
55
+
56
+ sig { override.params(version: VersionParameter).void }
57
+ def initialize(version)
58
+ @version_string = T.let(version.to_s, String)
59
+
60
+ raise ArgumentError, "Malformed version string #{version}" unless self.class.correct?(version)
61
+
62
+ # Validate no empty segments
63
+ validate_version!(@version_string)
64
+
65
+ # Parse epoch, main version, and local version
66
+ parts = parse_epoch_and_local(@version_string)
67
+
68
+ @epoch = T.let(parts.epoch.to_i, Integer)
69
+ @version_parts = T.let(parse_components(parts.main), T::Array[T.any(Integer, String)])
70
+ @local_parts = T.let(
71
+ parts.local ? parse_components(parts.local) : nil,
72
+ T.nilable(T::Array[T.any(Integer, String)])
73
+ )
74
+
75
+ super
76
+ end
77
+
78
+ sig { override.params(other: T.untyped).returns(T.nilable(Integer)) }
79
+ def <=>(other)
80
+ return nil unless other.is_a?(Dependabot::Conda::Version)
81
+
82
+ # Step 1: Compare epochs (numerically)
83
+ epoch_comparison = @epoch <=> other.epoch
84
+ return epoch_comparison unless epoch_comparison.zero?
85
+
86
+ # Step 2: Compare version parts (segment by segment with fillvalue)
87
+ version_comparison = compare_parts(@version_parts, other.version_parts)
88
+ return version_comparison unless version_comparison.zero?
89
+
90
+ # Step 3: Compare local parts (if present)
91
+ compare_local_parts(@local_parts, other.local_parts)
92
+ end
93
+
94
+ private
95
+
96
+ # Validates version string for malformed segments
97
+ sig { params(version_str: String).void }
98
+ def validate_version!(version_str)
99
+ main_version = parse_epoch_and_local(version_str).main
100
+
101
+ # Check for empty segments (consecutive dots, leading/trailing dots)
102
+ return unless main_version.include?("..") || main_version.match?(/^\./) || main_version.match?(/\.$/)
103
+
104
+ raise ArgumentError, "Empty version segments not allowed in #{version_str}"
105
+ end
106
+
107
+ # Parses epoch and local version from version string
108
+ # Returns VersionParts struct with epoch, main, and local fields
109
+ sig { params(version_str: String).returns(T.untyped) }
110
+ def parse_epoch_and_local(version_str)
111
+ # Split on '!' for epoch
112
+ parts = version_str.split("!", 2)
113
+ if parts.length == 2
114
+ epoch_str = parts[0]
115
+ remainder = T.must(parts[1])
116
+ else
117
+ epoch_str = "0"
118
+ remainder = parts[0]
119
+ end
120
+
121
+ # Split on '+' for local version
122
+ parts = T.must(remainder).split("+", 2)
123
+ main_version = parts[0]
124
+ local_version = parts[1]
125
+
126
+ VersionParts.new(epoch: epoch_str, main: T.must(main_version), local: local_version)
127
+ end
128
+
129
+ # Parses version components into normalized segments
130
+ # Normalizes underscores to dots, handles special strings (dev, post)
131
+ # Splits alphanumeric segments like "a1" into ["a", 1]
132
+ sig { params(version_str: String).returns(T::Array[T.any(Integer, String)]) }
133
+ def parse_components(version_str)
134
+ # Normalize underscores to dots
135
+ normalized = version_str.tr("_", ".")
136
+
137
+ # Split on dots and dashes
138
+ raw_segments = normalized.split(/[.\-]/)
139
+
140
+ # Process each segment
141
+ segments = T.let([], T::Array[T.any(Integer, String)])
142
+ raw_segments.each do |segment|
143
+ next if segment.empty?
144
+
145
+ process_segment(segment, segments)
146
+ end
147
+
148
+ segments
149
+ end
150
+
151
+ # Process a single version segment and add results to segments array
152
+ sig do
153
+ params(
154
+ segment: String,
155
+ segments: T::Array[T.any(Integer, String)]
156
+ ).void
157
+ end
158
+ def process_segment(segment, segments)
159
+ # Key insight: Pre-release markers are only recognized when EMBEDDED in
160
+ # numeric segments (e.g., "0a1" in "1.0a1"), NOT when they appear as
161
+ # separate dot-delimited components (e.g., "rc1" in "2.rc1").
162
+ lower_seg = segment.downcase
163
+ has_embedded_prerelease = embedded_prerelease?(lower_seg)
164
+
165
+ subsegments = T.cast(segment.scan(/\d+|[a-z]+/i), T::Array[String])
166
+
167
+ if has_embedded_prerelease
168
+ process_prerelease_subsegments(subsegments, segments)
169
+ else
170
+ process_normal_subsegments(subsegments, segments)
171
+ end
172
+ end
173
+
174
+ # Check if segment contains an embedded pre-release marker
175
+ sig { params(lower_seg: String).returns(T::Boolean) }
176
+ def embedded_prerelease?(lower_seg)
177
+ # Embedded: digits + a/b/rc + required digits (e.g., "0a1", "10b2", "2rc1")
178
+ lower_seg.match?(/^\d+(a|alpha|b|beta|rc|c)\d+$/) ||
179
+ # Embedded: digits + dev/post + optional digits (e.g., "0dev", "10post1")
180
+ lower_seg.match?(/^\d+(dev|post)\d*$/)
181
+ end
182
+
183
+ # Process subsegments that contain pre-release markers
184
+ sig do
185
+ params(
186
+ subsegments: T::Array[String],
187
+ segments: T::Array[T.any(Integer, String)]
188
+ ).void
189
+ end
190
+ def process_prerelease_subsegments(subsegments, segments)
191
+ subsegments.each do |subseg|
192
+ lower_subseg = subseg.downcase
193
+ segments << if lower_subseg.match?(/^(dev|a|alpha|b|beta|rc|c|post)$/)
194
+ normalize_prerelease_segment(subseg)
195
+ elsif subseg.match?(/^\d+$/)
196
+ subseg.to_i
197
+ else
198
+ subseg.downcase
199
+ end
200
+ end
201
+ end
202
+
203
+ # Process normal subsegments (no pre-release markers)
204
+ sig do
205
+ params(
206
+ subsegments: T::Array[String],
207
+ segments: T::Array[T.any(Integer, String)]
208
+ ).void
209
+ end
210
+ def process_normal_subsegments(subsegments, segments)
211
+ subsegments.each do |subseg|
212
+ segments << if subseg.match?(/^\d+$/)
213
+ subseg.to_i
214
+ else
215
+ subseg.downcase
216
+ end
217
+ end
218
+ end
219
+
220
+ # Normalizes a pre-release or post-release segment
221
+ # Only called for confirmed pre-release/post-release patterns
222
+ sig { params(segment: String).returns(T.any(Integer, String)) }
223
+ def normalize_prerelease_segment(segment)
224
+ lower_segment = segment.downcase
225
+
226
+ # Pre-releases: use negative integers to sort before 0
227
+ return -4 if dev_prerelease?(lower_segment)
228
+ return -3 if alpha_prerelease?(lower_segment)
229
+ return -2 if beta_prerelease?(lower_segment)
230
+ return -1 if rc_prerelease?(lower_segment)
231
+
232
+ # Post-releases: use ~ prefix to sort after everything
233
+ return "~#{lower_segment}" if post_release?(lower_segment)
234
+
235
+ lower_segment
236
+ end
237
+
238
+ # Check if segment is dev pre-release
239
+ sig { params(lower_segment: String).returns(T::Boolean) }
240
+ def dev_prerelease?(lower_segment)
241
+ lower_segment == "dev" || lower_segment.match?(/^dev\d/)
242
+ end
243
+
244
+ # Check if segment is alpha pre-release
245
+ sig { params(lower_segment: String).returns(T::Boolean) }
246
+ def alpha_prerelease?(lower_segment)
247
+ lower_segment == "a" || lower_segment == "alpha" || lower_segment.match?(/^(a|alpha)\d/)
248
+ end
249
+
250
+ # Check if segment is beta pre-release
251
+ sig { params(lower_segment: String).returns(T::Boolean) }
252
+ def beta_prerelease?(lower_segment)
253
+ lower_segment == "b" || lower_segment == "beta" || lower_segment.match?(/^(b|beta)\d/)
254
+ end
255
+
256
+ # Check if segment is rc pre-release
257
+ sig { params(lower_segment: String).returns(T::Boolean) }
258
+ def rc_prerelease?(lower_segment)
259
+ lower_segment == "rc" || lower_segment == "c" || lower_segment.match?(/^(rc|c)\d/)
260
+ end
261
+
262
+ # Check if segment is post-release
263
+ sig { params(lower_segment: String).returns(T::Boolean) }
264
+ def post_release?(lower_segment)
265
+ lower_segment == "post" || lower_segment.start_with?("post")
266
+ end
267
+
268
+ # Compares two arrays of version parts with fillvalue insertion
269
+ sig do
270
+ params(
271
+ parts1: T::Array[T.any(Integer, String)],
272
+ parts2: T::Array[T.any(Integer, String)]
273
+ ).returns(Integer)
274
+ end
275
+ def compare_parts(parts1, parts2)
276
+ max_length = [parts1.length, parts2.length].max
277
+
278
+ max_length.times do |i|
279
+ # Insert fillvalue 0 for missing segments
280
+ seg1 = parts1[i] || 0
281
+ seg2 = parts2[i] || 0
282
+
283
+ result = compare_single_segment(seg1, seg2)
284
+ return result unless result.zero?
285
+ end
286
+
287
+ 0 # All segments equal
288
+ end
289
+
290
+ # Compares two individual segments
291
+ # Rules:
292
+ # - Both integers: numeric comparison
293
+ # - Both strings: case-insensitive lexicographic comparison
294
+ # - Mixed types: Integer < String (numeric versions sort before pre-releases)
295
+ sig { params(seg1: T.any(Integer, String), seg2: T.any(Integer, String)).returns(Integer) }
296
+ def compare_single_segment(seg1, seg2)
297
+ # Both integers: numeric comparison
298
+ return seg1 <=> seg2 if seg1.is_a?(Integer) && seg2.is_a?(Integer)
299
+
300
+ # Both strings: case-insensitive lexicographic
301
+ # (already normalized to lowercase in parse_components)
302
+ return T.must(seg1 <=> seg2) if seg1.is_a?(String) && seg2.is_a?(String)
303
+
304
+ # Mixed types: Integer < String
305
+ # This means 1.0.0 (with fillvalue 0) < 1.0.0a (with "a")
306
+ seg1.is_a?(Integer) ? -1 : 1
307
+ end
308
+
309
+ # Compares local version parts
310
+ # - nil local version sorts before any local version
311
+ # - Both nil: equal
312
+ # - Otherwise compare using same rules as version parts
313
+ sig do
314
+ params(
315
+ local1: T.nilable(T::Array[T.any(Integer, String)]),
316
+ local2: T.nilable(T::Array[T.any(Integer, String)])
317
+ ).returns(Integer)
318
+ end
319
+ def compare_local_parts(local1, local2)
320
+ # Both nil: equal
321
+ return 0 if local1.nil? && local2.nil?
322
+
323
+ # One nil: nil sorts before any local version
324
+ return -1 if local1.nil?
325
+ return 1 if local2.nil?
326
+
327
+ # Both present: compare using same rules as version parts
328
+ compare_parts(local1, local2)
329
+ end
19
330
  end
20
331
  end
21
332
  end
@@ -17,15 +17,16 @@ Dependabot::PullRequestCreator::Labeler
17
17
  .register_label_details("conda", name: "conda", colour: "44a047")
18
18
 
19
19
  require "dependabot/dependency"
20
- # Conda manages Python packages, so use the same production check as Python
20
+ # Conda manages packages from multiple ecosystems (Python, R, Julia, system tools)
21
+ # and can also contain pip dependencies for Python packages from PyPI
21
22
  Dependabot::Dependency.register_production_check(
22
23
  "conda",
23
24
  lambda do |groups|
24
25
  return true if groups.empty?
25
26
  return true if groups.include?("default")
26
- return true if groups.include?("dependencies")
27
+ return true if groups.include?("dependencies") # Conda packages
27
28
 
28
- groups.include?("pip")
29
+ groups.include?("pip") # Pip packages (Python from PyPI)
29
30
  end
30
31
  )
31
32
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-conda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.348.1
4
+ version: 0.350.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.348.1
18
+ version: 0.350.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.348.1
25
+ version: 0.350.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: dependabot-python
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 0.348.1
32
+ version: 0.350.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 0.348.1
39
+ version: 0.350.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: debug
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -247,33 +247,34 @@ dependencies:
247
247
  - - "~>"
248
248
  - !ruby/object:Gem::Version
249
249
  version: '1.9'
250
- description: Dependabot-Conda provides support for bumping Python packages in Conda
251
- environment.yml files via Dependabot. If you want support for multiple package managers,
252
- you probably want the meta-gem dependabot-omnibus.
250
+ description: Dependabot-Conda provides support for updating Conda packages defined
251
+ in Conda environment.yml files. Routes conda packages to Conda channel APIs and
252
+ pip packages to PyPI for accurate version information.
253
253
  email: opensource@github.com
254
254
  executables: []
255
255
  extensions: []
256
256
  extra_rdoc_files: []
257
257
  files:
258
258
  - lib/dependabot/conda.rb
259
+ - lib/dependabot/conda/conda_registry_client.rb
259
260
  - lib/dependabot/conda/file_fetcher.rb
260
261
  - lib/dependabot/conda/file_parser.rb
261
262
  - lib/dependabot/conda/file_updater.rb
262
263
  - lib/dependabot/conda/metadata_finder.rb
263
264
  - lib/dependabot/conda/name_normaliser.rb
264
265
  - lib/dependabot/conda/package_manager.rb
265
- - lib/dependabot/conda/python_package_classifier.rb
266
266
  - lib/dependabot/conda/requirement.rb
267
267
  - lib/dependabot/conda/update_checker.rb
268
268
  - lib/dependabot/conda/update_checker/latest_version_finder.rb
269
269
  - lib/dependabot/conda/update_checker/requirement_translator.rb
270
+ - lib/dependabot/conda/update_checker/requirements_updater.rb
270
271
  - lib/dependabot/conda/version.rb
271
272
  homepage: https://github.com/dependabot/dependabot-core
272
273
  licenses:
273
274
  - MIT
274
275
  metadata:
275
276
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
276
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.348.1
277
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.350.0
277
278
  rdoc_options: []
278
279
  require_paths:
279
280
  - lib
@@ -1,88 +0,0 @@
1
- # typed: strong
2
- # frozen_string_literal: true
3
-
4
- require "sorbet-runtime"
5
-
6
- module Dependabot
7
- module Conda
8
- class PythonPackageClassifier
9
- extend T::Sig
10
-
11
- # Known non-Python packages that should be ignored
12
- NON_PYTHON_PATTERNS = T.let(
13
- [
14
- /^r-/i, # R packages (r-base, r-essentials, etc.)
15
- /^r$/i, # R language itself
16
- /^python$/i, # Python interpreter (conda-specific, not on PyPI)
17
- /^git$/i, # Git version control
18
- /^gcc$/i, # GCC compiler
19
- /^cmake$/i, # CMake build system
20
- /^make$/i, # Make build tool
21
- /^curl$/i, # cURL utility
22
- /^wget$/i, # Wget utility
23
- /^vim$/i, # Vim editor
24
- /^nano$/i, # Nano editor
25
- /^nodejs$/i, # Node.js runtime
26
- /^java$/i, # Java runtime
27
- /^go$/i, # Go language
28
- /^rust$/i, # Rust language
29
- /^julia$/i, # Julia language
30
- /^perl$/i, # Perl language
31
- /^ruby$/i, # Ruby language
32
- # System libraries
33
- /^openssl$/i, # OpenSSL
34
- /^zlib$/i, # zlib compression
35
- /^libffi$/i, # Foreign Function Interface library
36
- /^ncurses$/i, # Terminal control library
37
- /^readline$/i, # Command line editing
38
- # Compiler and build tools
39
- /^_libgcc_mutex$/i,
40
- /^_openmp_mutex$/i,
41
- /^binutils$/i,
42
- /^gxx_linux-64$/i,
43
- # Multimedia libraries
44
- /^ffmpeg$/i, # Video processing
45
- /^opencv$/i, # Computer vision (note: opencv-python is different)
46
- /^imageio$/i # Image I/O (note: imageio python package is different)
47
- ].freeze,
48
- T::Array[Regexp]
49
- )
50
-
51
- # Determine if a package name represents a Python package
52
- sig { params(package_name: String).returns(T::Boolean) }
53
- def self.python_package?(package_name)
54
- return false if package_name.empty?
55
-
56
- # Extract just the package name without version or channel information
57
- normalized_name = extract_package_name(package_name).downcase.strip
58
- return false if normalized_name.empty?
59
-
60
- # Check if it's explicitly a non-Python package
61
- return false if NON_PYTHON_PATTERNS.any? { |pattern| normalized_name.match?(pattern) }
62
-
63
- # Block obvious binary/system files
64
- return false if normalized_name.match?(/\.(exe|dll|so|dylib)$/i)
65
- return false if normalized_name.match?(/^lib.+\.a$/i) # Static libraries
66
-
67
- # Block system mutexes
68
- return false if normalized_name.match?(/^_[a-z0-9]+_mutex$/i)
69
-
70
- # Default: treat as Python package
71
- # This aligns with the strategic decision to focus on Python packages
72
- # Most packages in conda environments are Python packages
73
- true
74
- end
75
-
76
- # Extract package name from conda specification (remove channel prefix if present)
77
- sig { params(spec: String).returns(String) }
78
- def self.extract_package_name(spec)
79
- # Handle channel specifications like "conda-forge::numpy=1.21.0"
80
- parts = spec.split("::")
81
- package_spec = parts.last || spec
82
-
83
- # Extract package name (before = or space or version operators)
84
- package_spec.split(/[=<>!~\s]/).first&.strip || spec
85
- end
86
- end
87
- end
88
- end