miga-base 1.4.3.1 → 1.4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a71abecf373fd0b61a6148010a4c4f2ae08bb1088fbc5c185c95a2e35e761d74
4
- data.tar.gz: 021b16610dbf57f81e31e04745bbf04189fe82a836245883c4f68fe80ef99cf6
3
+ metadata.gz: b3befac28b6f332444a88067d1e04a8fe9f3d77e3800b6363be17fec49cee367
4
+ data.tar.gz: a68987776138c7752183a4eadc14fe42ca95eb971b721bf41830f16548bf859e
5
5
  SHA512:
6
- metadata.gz: ebdc34e46e478e58d83842ac93c913e413cfc9ee526429a6b4be026fa73217d5fc44863c896b67d6eb11c70cf527273a659f2d429caf9b873146d40f6a31dce7
7
- data.tar.gz: 17d801e447570c9390295828f3317649080c4d9d2d1c6f9e1a39f8c3903b5bd8aa2a6f2dbc9e702210d75dc4e72a99b14bce6bd1b56c856bf1b46d0ebe53e220
6
+ metadata.gz: 44ed6636bbbd92e7d383e6b462e17c3dfc7da82535224b380fe8c05afd243ed9b3d86c81fc55c784973f1578377030f39288ab939cec5059100da19cdd314c8a
7
+ data.tar.gz: b5acca6e95002b45bdf18a8dab953ead9cb9d62c048602e26ef2c82981880d9794c96ba85b801440a0f9581e7ef3e2d43598456897bb3752f5cf024db4b08bf9
@@ -18,7 +18,7 @@ def uri_safe_join(*parts)
18
18
  end
19
19
 
20
20
  module MiGA::RemoteDataset::Base
21
- @@_NCBI_DATASETS = 'https://api.ncbi.nlm.nih.gov/datasets/v2alpha/'
21
+ @@_NCBI_DATASETS = 'https://api.ncbi.nlm.nih.gov/datasets/v2/'
22
22
  @@_EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/'
23
23
  @@_EBI_API = 'https://www.ebi.ac.uk/Tools/'
24
24
  @@_GTDB_API = 'https://gtdb-api.ecogenomic.org/'
@@ -173,6 +173,21 @@ module MiGA::RemoteDataset::Base
173
173
  end
174
174
  end
175
175
  },
176
+ ncbi_datasets_report: {
177
+ dbs: { genome: { stage: :metadata, format: :json } },
178
+ uri: lambda do |opts|
179
+ uri_safe_join(
180
+ @@_NCBI_DATASETS, opts[:db], :accession, opts[:ids], :dataset_report
181
+ )
182
+ end,
183
+ method: :get,
184
+ headers: lambda do |opts|
185
+ {}.tap do |h|
186
+ h['Accept'] = 'application/json' if opts[:format] == :json
187
+ h['api-key'] = ENV['NCBI_API_KEY'] if ENV['NCBI_API_KEY']
188
+ end
189
+ end
190
+ },
176
191
  ncbi_datasets: {
177
192
  dbs: {
178
193
  genome: {
@@ -19,9 +19,6 @@ class MiGA::RemoteDataset
19
19
  getter = database_hash[:getter] || :download
20
20
  action = database_hash[:method] || universe_hash[:method]
21
21
 
22
- # Clean IDs
23
- ids =
24
-
25
22
  # Return options
26
23
  {
27
24
  universe: universe, db: db, ids: ids.is_a?(Array) ? ids : [ids],
@@ -60,11 +57,10 @@ class MiGA::RemoteDataset
60
57
  begin
61
58
  ncbi_asm_get(opts)
62
59
  rescue
63
- # The actual error thrown by `ncbi_asm_get` is rather uniformative and
64
- # could change in the future. Right now, the URL resolves correctly, but
65
- # the file is corrupted, so the actual error is `Zip::Error`. Hopefully
66
- # NCBI will implement an actual 404 Error that can be caught here more
67
- # narrowly, but for now it's safer to catch broadly here.
60
+ # Missing or unavailable assemblies are reported by `ncbi_asm_get` as
61
+ # MiGA::RemoteDataError (or its subclass MiGA::RemoteDataMissingError),
62
+ # but network errors and other transient failures can surface with
63
+ # different classes, so it's safer to catch broadly here.
68
64
 
69
65
  opts_embl = opts.dup.merge(universe: :ebi, db: :embl)
70
66
  download_get(opts_embl)
@@ -79,12 +75,25 @@ class MiGA::RemoteDataset
79
75
  # +file+ (mandatory): String, assembly saved here
80
76
  # +extra+: Hash, passed to download
81
77
  # +format+: String, ignored
78
+ #
79
+ # Unversioned accessions (e.g., GCA_000175575) are first resolved to their
80
+ # current version (see +ncbi_asm_resolve_acc+), and the resolved accessions
81
+ # are registered in the +:ncbi_asm+ metadata entry of +obj+, if any.
82
+ #
83
+ # Raises MiGA::RemoteDataMissingError if the downloaded archive is invalid
84
+ # or doesn't contain any genomic FastA file
82
85
  def ncbi_asm_get(opts)
83
86
  require 'tempfile'
84
87
  require 'zip'
85
88
 
89
+ ids = (opts[:ids].is_a?(Array) ? opts[:ids] : [opts[:ids]])
90
+ acc = ids.map { |id| ncbi_asm_resolve_acc(id) }
91
+ if acc != ids && opts[:obj].respond_to?(:metadata)
92
+ opts[:obj].metadata[:ncbi_asm] = acc.join(',')
93
+ end
94
+
86
95
  zipped = download(
87
- :ncbi_datasets_download, :genome, opts[:ids],
96
+ :ncbi_datasets_download, :genome, acc,
88
97
  :zip, nil, opts[:extra], opts[:obj]
89
98
  )
90
99
  zip_tmp = Tempfile.new(['asm', '.zip'], encoding: zipped.encoding.to_s)
@@ -92,22 +101,74 @@ class MiGA::RemoteDataset
92
101
  zip_tmp.close
93
102
 
94
103
  o = ''
104
+ found = false
95
105
  ofh = opts[:file] ? File.open(opts[:file], 'w') : nil
96
- Zip::File.open(zip_tmp.path) do |zfh|
97
- zfh.each do |entry|
98
- if entry.file? && entry.name =~ /_genomic\.fna$/
99
- DEBUG "Extracting: #{entry.name}"
100
- entry.get_input_stream do |ifh|
101
- cont = MiGA::MiGA.normalize_encoding(ifh.read).chomp + "\n"
102
- ofh&.print(cont)
103
- o += cont
106
+ begin
107
+ Zip::File.open(zip_tmp.path) do |zfh|
108
+ zfh.each do |entry|
109
+ if entry.file? && entry.name =~ /_genomic\.fna$/
110
+ DEBUG "Extracting: #{entry.name}"
111
+ found = true
112
+ entry.get_input_stream do |ifh|
113
+ cont = MiGA::MiGA.normalize_encoding(ifh.read).chomp + "\n"
114
+ ofh&.print(cont)
115
+ o += cont
116
+ end
104
117
  end
105
118
  end
106
119
  end
120
+ rescue Zip::Error => e
121
+ raise MiGA::RemoteDataMissingError.new(
122
+ "NCBI Datasets returned an invalid archive for #{acc.join(',')} " \
123
+ "(#{zipped.bytesize} bytes): #{e.message}"
124
+ )
125
+ end
126
+ unless found
127
+ raise MiGA::RemoteDataMissingError.new(
128
+ "NCBI Datasets returned no genomic sequences for #{acc.join(',')}"
129
+ )
107
130
  end
108
- ofh&.close
109
- File.unlink(zip_tmp.path)
110
131
  o
132
+ ensure
133
+ ofh&.close
134
+ zip_tmp&.close!
135
+ end
136
+
137
+ ##
138
+ # Resolve the NCBI Assembly Accession +acc+ to its current versioned
139
+ # accession using NCBI Datasets if it lacks a version (e.g., GCA_000175575
140
+ # is resolved to GCA_000175575.2). Any other accession is returned as is.
141
+ #
142
+ # Raises MiGA::RemoteDataMissingError if the accession is not found, or
143
+ # MiGA::RemoteDataError if the response from NCBI cannot be interpreted
144
+ def ncbi_asm_resolve_acc(acc)
145
+ return acc unless acc.to_s =~ /\AGC[AF]_\d{9}\z/
146
+
147
+ doc = download(:ncbi_datasets_report, :genome, acc, :json)
148
+ begin
149
+ report = MiGA::Json.parse(doc, contents: true)
150
+ rescue => e
151
+ raise MiGA::RemoteDataError.new(
152
+ "Cannot resolve NCBI Assembly Accession #{acc}, " \
153
+ "unexpected response from NCBI Datasets: #{e.message}"
154
+ )
155
+ end
156
+ unless report.is_a?(Hash) && (report.empty? || report.key?(:reports))
157
+ raise MiGA::RemoteDataError.new(
158
+ "Cannot resolve NCBI Assembly Accession #{acc}, " \
159
+ "unexpected response from NCBI Datasets: #{doc[0, 200]}"
160
+ )
161
+ end
162
+
163
+ entry = (report[:reports] || []).first || {}
164
+ out = entry[:current_accession] || entry[:accession]
165
+ if out.nil? || out.empty?
166
+ raise MiGA::RemoteDataMissingError.new(
167
+ "NCBI Assembly Accession not found: #{acc}"
168
+ )
169
+ end
170
+ DEBUG "Resolved NCBI Assembly Accession: #{acc} -> #{out}"
171
+ out
111
172
  end
112
173
 
113
174
  ##
@@ -116,6 +177,7 @@ class MiGA::RemoteDataset
116
177
  def ncbi_gb_get(opts)
117
178
  # Simply use defaults, but ensure that the URL can be properly formed
118
179
  o = download_rest(opts.merge(universe: :ncbi, db: :nuccore))
180
+ ncbi_efetch_check(o, opts[:ids])
119
181
  return o unless o.strip.empty?
120
182
 
121
183
  begin
@@ -137,9 +199,35 @@ class MiGA::RemoteDataset
137
199
  end
138
200
  ids.each_slice(20).map do |bunch|
139
201
  download_rest(opts.merge(universe: :ncbi, db: :nuccore, ids: bunch))
202
+ .tap { |doc| ncbi_efetch_check(doc, bunch) }
140
203
  end.join("\n")
141
204
  end
142
205
 
206
+ ##
207
+ # Check if +doc+ is an error message returned by NCBI efetch for +ids+
208
+ # (with HTTP status 200), and raise an exception if so. Unrecognized
209
+ # IDs raise MiGA::RemoteDataMissingError, and any other error raises
210
+ # MiGA::RemoteDataError
211
+ def ncbi_efetch_check(doc, ids)
212
+ return unless doc =~ /\A\s*Error:/
213
+
214
+ # NCBI sometimes spaces out the characters in the message, as in
215
+ # "F a i l e d t o u n d e r s t a n d i d"
216
+ msg = doc.strip.sub(/\AError:\s*/, '')
217
+ msg = msg.gsub(/(?<! ) (?! )/, '') if msg =~ /\A(\S ){3}/
218
+ msg = msg.gsub(/\s+/, ' ').strip
219
+ ids = ids.is_a?(Array) ? ids.join(',') : ids.to_s
220
+
221
+ if msg =~ /Failed to understand id|not found/i
222
+ raise MiGA::RemoteDataMissingError.new(
223
+ "NCBI nuccore entry not found: #{ids} (efetch: #{msg})"
224
+ )
225
+ end
226
+ raise MiGA::RemoteDataError.new(
227
+ "NCBI efetch failed for nuccore entry #{ids}: #{msg}"
228
+ )
229
+ end
230
+
143
231
  ##
144
232
  # Download data using the GET method. Supported +opts+ (Hash) include:
145
233
  # +universe+ (mandatory): Symbol
@@ -212,21 +300,52 @@ class MiGA::RemoteDataset
212
300
  ##
213
301
  # Looks for the entry +id+ in +dbfrom+, and returns the linked
214
302
  # identifier in +db+ (or nil).
303
+ #
304
+ # Raises MiGA::RemoteDataMissingError if NCBI doesn't recognize +id+ in
305
+ # +dbfrom+, or MiGA::RemoteDataError if the response cannot be interpreted
306
+ # even after one retry
215
307
  def ncbi_map(id, dbfrom, db)
308
+ attempts ||= 0
216
309
  doc = download(:ncbi_map, dbfrom, id, :json, nil, db: db)
217
310
  return if doc.empty?
218
311
 
219
- begin
220
- tree = MiGA::Json.parse(doc, contents: true)
221
- rescue => e
222
- sleep 5 # <- Usually caused by busy servers: BLOB ID IS NOT IMPLEMENTED
223
- DEBUG "RETRYING after: #{e}"
224
- doc = download(:ncbi_map, dbfrom, id, :json, nil, db: db)
225
- return if doc.empty?
226
- tree = MiGA::Json.parse(doc, contents: true)
312
+ # NCBI may include raw control characters within strings (e.g., in ERROR
313
+ # messages), which is invalid JSON, but harmless to replace with spaces
314
+ tree = MiGA::Json.parse(doc.gsub(/[[:cntrl:]]/, ' '), contents: true)
315
+ linkset = tree.is_a?(Hash) ? (tree.dig(:linksets, 0) || {}) : {}
316
+ error = linkset[:ERROR] ||
317
+ (linkset[:linksetdbs] || []).map { |i| i[:ERROR] }.compact.first
318
+ if error
319
+ # Errors with an empty list of IDs indicate that +id+ is not recognized
320
+ # in +dbfrom+ (e.g., BLOB ID IS NOT IMPLEMENTED), so there's no point
321
+ # in retrying
322
+ if (linkset[:ids] || []).empty?
323
+ raise MiGA::RemoteDataMissingError.new(
324
+ "NCBI #{dbfrom} entry not found: #{id} " \
325
+ "(elink: #{error.to_s.squeeze(' ').strip})"
326
+ )
327
+ end
328
+ raise MiGA::RemoteDataError.new(
329
+ "NCBI elink failed for #{dbfrom} entry #{id}: " \
330
+ "#{error.to_s.squeeze(' ').strip}"
331
+ )
227
332
  end
228
333
 
229
- tree&.dig(:linksets, 0, :linksetdbs, 0, :links, 0)
334
+ linkset.dig(:linksetdbs, 0, :links, 0)
335
+ rescue MiGA::RemoteDataMissingError
336
+ raise
337
+ rescue JSON::ParserError, MiGA::RemoteDataError => e
338
+ # Any other error in the response is possibly transient (e.g., busy
339
+ # servers), so retry once before giving up
340
+ if (attempts += 1) > 1
341
+ raise e if e.is_a?(MiGA::RemoteDataError)
342
+ raise MiGA::RemoteDataError.new(
343
+ "Unexpected response from NCBI elink for #{dbfrom} entry #{id}: #{e}"
344
+ )
345
+ end
346
+ sleep 5
347
+ DEBUG "RETRYING after: #{e}"
348
+ retry
230
349
  end
231
350
 
232
351
  ##
@@ -368,11 +368,17 @@ class MiGA::RemoteDataset < MiGA::MiGA
368
368
  def get_type_status_ncbi_nuccore(metadata)
369
369
  return metadata if metadata[:ncbi_nuccore].nil?
370
370
 
371
- biosample =
372
- self.class.ncbi_map(metadata[:ncbi_nuccore], :nuccore, :biosample)
373
- return metadata if biosample.nil?
374
-
375
- asm = self.class.ncbi_map(biosample, :biosample, :assembly)
371
+ begin
372
+ biosample =
373
+ self.class.ncbi_map(metadata[:ncbi_nuccore], :nuccore, :biosample)
374
+ return metadata if biosample.nil?
375
+
376
+ asm = self.class.ncbi_map(biosample, :biosample, :assembly)
377
+ rescue MiGA::RemoteDataMissingError => e
378
+ # Type status is best-effort, missing entries are reported on download
379
+ MiGA::MiGA.DEBUG "Cannot determine type status: #{e}"
380
+ return metadata
381
+ end
376
382
  metadata[:ncbi_asm] = asm.to_s unless asm.nil?
377
383
  get_type_status_ncbi_asm metadata
378
384
  end
data/lib/miga/version.rb CHANGED
@@ -12,7 +12,7 @@ module MiGA
12
12
  # - String indicating release status:
13
13
  # - rc* release candidate, not released as gem
14
14
  # - [0-9]+ stable release, released as gem
15
- VERSION = [1.4, 3, 1].freeze
15
+ VERSION = [1.4, 4, 0].freeze
16
16
 
17
17
  ##
18
18
  # Nickname for the current major.minor version.
@@ -20,7 +20,7 @@ module MiGA
20
20
 
21
21
  ##
22
22
  # Date of the current gem relese.
23
- VERSION_DATE = Date.new(2026, 8, 7)
23
+ VERSION_DATE = Date.new(2026, 11, 9)
24
24
 
25
25
  ##
26
26
  # References of MiGA
@@ -215,6 +215,271 @@ class RemoteDatasetTest < Test::Unit::TestCase
215
215
  assert_equal('}', p[-1])
216
216
  end
217
217
 
218
+ def test_ncbi_datasets_report_request
219
+ # No remote access needed
220
+ rd = MiGA::RemoteDataset.new(
221
+ 'GCA_000175575', :genome, :ncbi_datasets_report
222
+ )
223
+ u = rd.download_uri
224
+ h = rd.download_headers
225
+
226
+ assert(u.is_a? URI)
227
+ assert_equal('https', u.scheme)
228
+ assert_equal('dataset_report', File.basename(u.path))
229
+ assert_include(u.path, '/accession/GCA_000175575/')
230
+
231
+ assert(h.is_a? Hash)
232
+ assert_equal(1, h.size)
233
+ assert_equal('application/json', h['Accept'])
234
+
235
+ ENV['NCBI_API_KEY'] = 'Not-a-real-key'
236
+ h = rd.download_headers
237
+ ENV.delete('NCBI_API_KEY')
238
+ assert_equal(2, h.size)
239
+ assert_equal('Not-a-real-key', h['api-key'])
240
+ end
241
+
242
+ def test_ncbi_asm_resolve_acc_offline
243
+ report = {
244
+ 'GCA_000175575' =>
245
+ '{"reports":[{"accession":"GCA_000175575.2",' \
246
+ '"current_accession":"GCA_000175575.2"}]}',
247
+ 'GCA_999999999' => '{}',
248
+ 'GCA_888888888' => '{"error":"Too Many Requests","code":429}',
249
+ 'GCA_777777777' => '<html>Service unavailable</html>'
250
+ }
251
+ calls = []
252
+ with_stubbed_download(lambda do |universe, _db, ids, *_rest|
253
+ assert_equal(:ncbi_datasets_report, universe)
254
+ calls << ids
255
+ report[ids]
256
+ end) do
257
+ rd = MiGA::RemoteDataset
258
+ assert_equal('GCA_000175575.2', rd.ncbi_asm_resolve_acc('GCA_000175575'))
259
+ assert_equal(['GCA_000175575'], calls)
260
+
261
+ # Versioned (or non-assembly) accessions are not resolved
262
+ assert_equal('GCF_000175575.2', rd.ncbi_asm_resolve_acc('GCF_000175575.2'))
263
+ assert_equal('M30502.1', rd.ncbi_asm_resolve_acc('M30502.1'))
264
+ assert_equal(1, calls.size)
265
+
266
+ # Missing accessions
267
+ assert_raise(MiGA::RemoteDataMissingError) do
268
+ rd.ncbi_asm_resolve_acc('GCA_999999999')
269
+ end
270
+
271
+ # Unexpected responses are not reported as missing data
272
+ %w[GCA_888888888 GCA_777777777].each do |acc|
273
+ err = assert_raise(MiGA::RemoteDataError) do
274
+ rd.ncbi_asm_resolve_acc(acc)
275
+ end
276
+ assert_not_instance_of(MiGA::RemoteDataMissingError, err)
277
+ end
278
+ end
279
+ end
280
+
281
+ def test_ncbi_asm_get_bad_zip_offline
282
+ require 'zip'
283
+ # A truncated archive like the one NCBI returns for unversioned accessions
284
+ truncated = "PK\x03\x04\x14\x00\x00\x00\x08\x00README.md".b
285
+ # A valid archive without any genomic FastA file
286
+ no_fna = Zip::OutputStream.write_buffer do |zos|
287
+ zos.put_next_entry('README.md')
288
+ zos.write('Nothing to see here')
289
+ end.string
290
+
291
+ [truncated, no_fna].each do |zipped|
292
+ with_stubbed_download(lambda do |universe, *_rest|
293
+ assert_equal(:ncbi_datasets_download, universe)
294
+ zipped
295
+ end) do
296
+ file = tmpfile('asm.fna')
297
+ err = assert_raise(MiGA::RemoteDataMissingError) do
298
+ MiGA::RemoteDataset.ncbi_asm_get(
299
+ ids: ['GCA_000175575.2'], file: file, extra: {}
300
+ )
301
+ end
302
+ assert_include(err.message, 'GCA_000175575.2')
303
+ end
304
+ end
305
+ end
306
+
307
+ def test_ncbi_asm_get_offline
308
+ require 'zip'
309
+ zipped = Zip::OutputStream.write_buffer do |zos|
310
+ zos.put_next_entry(
311
+ 'ncbi_dataset/data/GCA_000175575.2/GCA_000175575.2_ASM_genomic.fna'
312
+ )
313
+ zos.write(">ctg1\nACGT\n")
314
+ end.string
315
+ report =
316
+ '{"reports":[{"accession":"GCA_000175575.2",' \
317
+ '"current_accession":"GCA_000175575.2"}]}'
318
+
319
+ rd = MiGA::RemoteDataset.new('GCA_000175575', :assembly, :ncbi)
320
+ with_stubbed_download(lambda do |universe, _db, ids, *_rest|
321
+ case universe
322
+ when :ncbi_datasets_report
323
+ report
324
+ when :ncbi_datasets_download
325
+ assert_equal(['GCA_000175575.2'], ids)
326
+ zipped
327
+ end
328
+ end) do
329
+ file = tmpfile('asm.fna')
330
+ o = MiGA::RemoteDataset.ncbi_asm_get(
331
+ ids: rd.ids, file: file, extra: {}, obj: rd
332
+ )
333
+ assert_equal(">ctg1\nACGT\n", o)
334
+ assert_equal(">ctg1\nACGT\n", File.read(file))
335
+ assert_equal('GCA_000175575.2', rd.metadata[:ncbi_asm])
336
+ assert_equal('GCA_000175575', rd.metadata[:ncbi_assembly])
337
+ end
338
+ end
339
+
340
+ def test_ncbi_asm_resolve_acc
341
+ declare_remote_access
342
+ rd = MiGA::RemoteDataset
343
+ assert_equal('GCA_000175575.2', rd.ncbi_asm_resolve_acc('GCA_000175575'))
344
+ assert_equal('GCF_000175575.2', rd.ncbi_asm_resolve_acc('GCF_000175575'))
345
+ assert_raise(MiGA::RemoteDataMissingError) do
346
+ rd.ncbi_asm_resolve_acc('GCA_999999999')
347
+ end
348
+ end
349
+
350
+ def test_ncbi_asm_unversioned
351
+ declare_remote_access
352
+ rd = MiGA::RemoteDataset.new('GCA_000175575', :assembly, :ncbi)
353
+ ds = rd.save_to(project, 'unversioned')
354
+ assert_equal('GCA_000175575.2', ds.metadata[:ncbi_asm])
355
+ fna = ds.result(:assembly).file_path(:largecontigs)
356
+ assert_path_exist(fna)
357
+ assert_operator(File.size(fna), :>, 100_000)
358
+ end
359
+
360
+ # Payload returned by NCBI elink for IDs not recognized in +dbfrom+, which
361
+ # includes raw newlines within strings (and is therefore invalid JSON)
362
+ ELINK_NOT_FOUND =
363
+ '{"header":{"type":"elink","version":"0.3"},"linksets":[{' \
364
+ '"dbfrom":"nuccore","ids":[],"linksetdbs":[{"dbto":"biosample",' \
365
+ '"linkname":"nuccore_biosample","ERROR":"NCBI C++ Exception:' + "\n" +
366
+ ' Error: UNK_MODULE(CException::eInvalid) \"UNK_FILE\", line ' \
367
+ '18446744073709551615: UNK_FUNC --- ' + "\n" +
368
+ '"}],"ERROR":"BLOB ID IS NOT IMPLEMENTED"}]}'
369
+
370
+ def test_ncbi_map_offline
371
+ responses = {
372
+ # ID not recognized in nuccore
373
+ 'GCA_039803745.1' => ELINK_NOT_FOUND,
374
+ # ID recognized, but the link failed (possibly transient)
375
+ 'M30502.1' => ELINK_NOT_FOUND.sub('"ids":[]', '"ids":["1332355"]'),
376
+ # Unparseable response
377
+ 'CP005986.1' => '<html>Service unavailable</html>',
378
+ # Normal responses, with and without links
379
+ 'CP005986.2' =>
380
+ '{"header":{"type":"elink","version":"0.3"},"linksets":[{' \
381
+ '"dbfrom":"nuccore","ids":["640840007"],"linksetdbs":[{' \
382
+ '"dbto":"biosample","linkname":"nuccore_biosample",' \
383
+ '"links":["2469802"]}]}]}',
384
+ 'M30502.2' =>
385
+ '{"header":{"type":"elink","version":"0.3"},"linksets":[{' \
386
+ '"dbfrom":"nuccore","ids":["1332355"]}]}'
387
+ }
388
+ calls = []
389
+ sleeps = 0
390
+ with_stubbed_class_method(:sleep, ->(*_args) { sleeps += 1 }) do
391
+ with_stubbed_download(lambda do |universe, db, ids, *_rest|
392
+ assert_equal(:ncbi_map, universe)
393
+ assert_equal(:nuccore, db)
394
+ calls << ids
395
+ responses[ids]
396
+ end) do
397
+ rd = MiGA::RemoteDataset
398
+
399
+ # Not found: no retries
400
+ err = assert_raise(MiGA::RemoteDataMissingError) do
401
+ rd.ncbi_map('GCA_039803745.1', :nuccore, :biosample)
402
+ end
403
+ assert_include(err.message, 'GCA_039803745.1')
404
+ assert_include(err.message, 'BLOB ID IS NOT IMPLEMENTED')
405
+ assert_equal(1, calls.size)
406
+ assert_equal(0, sleeps)
407
+
408
+ # Other errors: retry once and fail with a generic RemoteDataError
409
+ %w[M30502.1 CP005986.1].each do |id|
410
+ calls.clear
411
+ sleeps = 0
412
+ err = assert_raise(MiGA::RemoteDataError) do
413
+ rd.ncbi_map(id, :nuccore, :biosample)
414
+ end
415
+ assert_not_instance_of(MiGA::RemoteDataMissingError, err)
416
+ assert_include(err.message, id)
417
+ assert_equal([id, id], calls)
418
+ assert_equal(1, sleeps)
419
+ end
420
+
421
+ # Normal responses
422
+ sleeps = 0
423
+ assert_equal('2469802', rd.ncbi_map('CP005986.2', :nuccore, :biosample))
424
+ assert_nil(rd.ncbi_map('M30502.2', :nuccore, :biosample))
425
+ assert_equal(0, sleeps)
426
+ end
427
+ end
428
+ end
429
+
430
+ def test_type_status_nuccore_not_found_offline
431
+ rd = MiGA::RemoteDataset.new('GCA_039803745.1', :nuccore, :ncbi)
432
+ with_stubbed_download(->(*_args) { ELINK_NOT_FOUND }) do
433
+ md = { ncbi_nuccore: 'GCA_039803745.1' }
434
+ assert_equal(md, rd.get_type_status(md))
435
+ end
436
+ end
437
+
438
+ def test_ncbi_gb_get_efetch_error_offline
439
+ responses = {
440
+ 'GCA_039803745.1' =>
441
+ "Error: F a i l e d t o u n d e r s t a n d i d : " \
442
+ "G C A _ 0 3 9 8 0 3 7 4 5 . 1 \n\n\n",
443
+ 'M30502.1' => "Error: Something else went wrong\n"
444
+ }
445
+ with_stubbed_class_method(:download_rest, ->(opts) {
446
+ responses[opts[:ids].first]
447
+ }) do
448
+ rd = MiGA::RemoteDataset
449
+ err = assert_raise(MiGA::RemoteDataMissingError) do
450
+ rd.ncbi_gb_get(ids: ['GCA_039803745.1'], file: tmpfile('a.fna'))
451
+ end
452
+ assert_equal(
453
+ 'NCBI nuccore entry not found: GCA_039803745.1 ' \
454
+ '(efetch: Failed to understand id: GCA_039803745.1)',
455
+ err.message
456
+ )
457
+
458
+ err = assert_raise(MiGA::RemoteDataError) do
459
+ rd.ncbi_gb_get(ids: ['M30502.1'], file: tmpfile('b.fna'))
460
+ end
461
+ assert_not_instance_of(MiGA::RemoteDataMissingError, err)
462
+ assert_include(err.message, 'Something else went wrong')
463
+ end
464
+ end
465
+
466
+ def test_ncbi_map_not_found
467
+ declare_remote_access
468
+ assert_raise(MiGA::RemoteDataMissingError) do
469
+ MiGA::RemoteDataset.ncbi_map('GCA_039803745.1', :nuccore, :biosample)
470
+ end
471
+ end
472
+
473
+ def test_nuccore_not_found
474
+ declare_remote_access
475
+ rd = MiGA::RemoteDataset.new('GCA_039803745.1', :nuccore, :ncbi)
476
+ err = assert_raise(MiGA::RemoteDataMissingError) do
477
+ rd.save_to(project, 'wrong_db')
478
+ end
479
+ assert_include(err.message, 'GCA_039803745.1')
480
+ assert_nil(project.dataset('wrong_db'))
481
+ end
482
+
218
483
  # This test is too expensive (too much time to run it!)
219
484
  # def test_net_timeout
220
485
  # declare_remote_access
@@ -222,4 +487,35 @@ class RemoteDatasetTest < Test::Unit::TestCase
222
487
  # rd = MiGA::RemoteDataset.new(bad, :assembly, :web)
223
488
  # assert_raise(Net::ReadTimeout) { rd.save_to(project, "bad") }
224
489
  # end
490
+
491
+ private
492
+
493
+ ##
494
+ # Temporarily replace MiGA::RemoteDataset.download with +fun+ while running
495
+ # the given block
496
+ def with_stubbed_download(fun, &blk)
497
+ with_stubbed_class_method(:download, fun, &blk)
498
+ end
499
+
500
+ ##
501
+ # Temporarily replace the class method +name+ of MiGA::RemoteDataset with
502
+ # +fun+ while running the given block. Methods inherited by the class (like
503
+ # +sleep+) can also be stubbed
504
+ def with_stubbed_class_method(name, fun)
505
+ klass = MiGA::RemoteDataset.singleton_class
506
+ orig = :"__orig_#{name}"
507
+ own =
508
+ klass.method_defined?(name, false) ||
509
+ klass.private_method_defined?(name, false)
510
+ klass.send(:alias_method, orig, name) if own
511
+ klass.send(:define_method, name) { |*args| fun.call(*args) }
512
+ yield
513
+ ensure
514
+ if own
515
+ klass.send(:alias_method, name, orig)
516
+ klass.send(:remove_method, orig)
517
+ else
518
+ klass.send(:remove_method, name)
519
+ end
520
+ end
225
521
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: miga-base
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.3.1
4
+ version: 1.4.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luis M. Rodriguez-R
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-07 00:00:00.000000000 Z
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: daemons