miga-base 1.4.3.0 → 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: 674190908d65b39a2a860c3f78c8f3449171ef5ce95005e6b36b008181db5530
4
- data.tar.gz: 3f6c844d8a17d4c74bbe7d3a4df8cbae41d10d0263f42c34a0cd555de9164a70
3
+ metadata.gz: b3befac28b6f332444a88067d1e04a8fe9f3d77e3800b6363be17fec49cee367
4
+ data.tar.gz: a68987776138c7752183a4eadc14fe42ca95eb971b721bf41830f16548bf859e
5
5
  SHA512:
6
- metadata.gz: 445197498099f28214ba6a0876dd1dc51df8c568789b4e70678ef0b894f200001cde3e30ed3a6d5c2ea7856c7212e3ba5aa03e18b965294b70a71eec2bff958d
7
- data.tar.gz: d665948deb9123884f9cc3774e51d36ac888eeb5c1fa877bd999ac1be643677c40371a70c075d91f38b043e08a49ec3d13eb3151d026ea1798d021c1e3bd5fa4
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/'
@@ -64,8 +64,9 @@ module MiGA::RemoteDataset::Base
64
64
  },
65
65
  gtdb: {
66
66
  dbs: {
67
- # This is a dummy entry plugged directly to +ncbi_asm_get+
68
- assembly: { stage: :assembly, format: :fasta, getter: :ncbi_asm },
67
+ assembly: {
68
+ stage: :assembly, format: :fasta, getter: :ncbi_or_embl_asm
69
+ },
69
70
  # The 'taxon' namespace actually returns a list of genomes (+format+)
70
71
  taxon: {
71
72
  stage: :metadata, format: :genomes, map_to: [:assembly],
@@ -84,8 +85,9 @@ module MiGA::RemoteDataset::Base
84
85
  },
85
86
  seqcode: {
86
87
  dbs: {
87
- # These are dummy entries plugged directly to +ncbi_*_get+
88
- assembly: { stage: :assembly, format: :fasta, getter: :ncbi_asm },
88
+ assembly: {
89
+ stage: :assembly, format: :fasta, getter: :ncbi_or_embl_asm
90
+ },
89
91
  nuccore: { stage: :assembly, format: :fasta, getter: :ncbi_gb },
90
92
  # This is the list of type genomes
91
93
  :'type-genomes' => { stage: :metadata, format: :json }
@@ -171,6 +173,21 @@ module MiGA::RemoteDataset::Base
171
173
  end
172
174
  end
173
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
+ },
174
191
  ncbi_datasets: {
175
192
  dbs: {
176
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],
@@ -49,6 +46,27 @@ class MiGA::RemoteDataset
49
46
  doc
50
47
  end
51
48
 
49
+ ##
50
+ # Attempts to download from NCBI Assembly and, if that fails, moves to EMBL
51
+ # instead. This is the default for SeqCode because some assemblies don't
52
+ # synchronize fast enough, and in the case of MAGs some never do. This is
53
+ # also the default for GTDB since it could be affected in the future too,
54
+ # although at present GTDB only sources data from NCBI. Supported +opts+ are
55
+ # the same as for +ncbi_asm_get+
56
+ def ncbi_or_embl_asm_get(opts)
57
+ begin
58
+ ncbi_asm_get(opts)
59
+ rescue
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.
64
+
65
+ opts_embl = opts.dup.merge(universe: :ebi, db: :embl)
66
+ download_get(opts_embl)
67
+ end
68
+ end
69
+
52
70
  ##
53
71
  # Download data from NCBI Assembly database using the REST method.
54
72
  # Supported +opts+ (Hash) include:
@@ -57,12 +75,25 @@ class MiGA::RemoteDataset
57
75
  # +file+ (mandatory): String, assembly saved here
58
76
  # +extra+: Hash, passed to download
59
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
60
85
  def ncbi_asm_get(opts)
61
86
  require 'tempfile'
62
87
  require 'zip'
63
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
+
64
95
  zipped = download(
65
- :ncbi_datasets_download, :genome, opts[:ids],
96
+ :ncbi_datasets_download, :genome, acc,
66
97
  :zip, nil, opts[:extra], opts[:obj]
67
98
  )
68
99
  zip_tmp = Tempfile.new(['asm', '.zip'], encoding: zipped.encoding.to_s)
@@ -70,22 +101,74 @@ class MiGA::RemoteDataset
70
101
  zip_tmp.close
71
102
 
72
103
  o = ''
104
+ found = false
73
105
  ofh = opts[:file] ? File.open(opts[:file], 'w') : nil
74
- Zip::File.open(zip_tmp.path) do |zfh|
75
- zfh.each do |entry|
76
- if entry.file? && entry.name =~ /_genomic\.fna$/
77
- DEBUG "Extracting: #{entry.name}"
78
- entry.get_input_stream do |ifh|
79
- cont = MiGA::MiGA.normalize_encoding(ifh.read).chomp + "\n"
80
- ofh&.print(cont)
81
- 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
82
117
  end
83
118
  end
84
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
+ )
85
130
  end
86
- ofh&.close
87
- File.unlink(zip_tmp.path)
88
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
89
172
  end
90
173
 
91
174
  ##
@@ -94,6 +177,7 @@ class MiGA::RemoteDataset
94
177
  def ncbi_gb_get(opts)
95
178
  # Simply use defaults, but ensure that the URL can be properly formed
96
179
  o = download_rest(opts.merge(universe: :ncbi, db: :nuccore))
180
+ ncbi_efetch_check(o, opts[:ids])
97
181
  return o unless o.strip.empty?
98
182
 
99
183
  begin
@@ -115,9 +199,35 @@ class MiGA::RemoteDataset
115
199
  end
116
200
  ids.each_slice(20).map do |bunch|
117
201
  download_rest(opts.merge(universe: :ncbi, db: :nuccore, ids: bunch))
202
+ .tap { |doc| ncbi_efetch_check(doc, bunch) }
118
203
  end.join("\n")
119
204
  end
120
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
+
121
231
  ##
122
232
  # Download data using the GET method. Supported +opts+ (Hash) include:
123
233
  # +universe+ (mandatory): Symbol
@@ -190,21 +300,52 @@ class MiGA::RemoteDataset
190
300
  ##
191
301
  # Looks for the entry +id+ in +dbfrom+, and returns the linked
192
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
193
307
  def ncbi_map(id, dbfrom, db)
308
+ attempts ||= 0
194
309
  doc = download(:ncbi_map, dbfrom, id, :json, nil, db: db)
195
310
  return if doc.empty?
196
311
 
197
- begin
198
- tree = MiGA::Json.parse(doc, contents: true)
199
- rescue => e
200
- sleep 5 # <- Usually caused by busy servers: BLOB ID IS NOT IMPLEMENTED
201
- DEBUG "RETRYING after: #{e}"
202
- doc = download(:ncbi_map, dbfrom, id, :json, nil, db: db)
203
- return if doc.empty?
204
- 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
+ )
205
332
  end
206
333
 
207
- 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
208
349
  end
209
350
 
210
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, 0].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, 7, 22)
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.0
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-07-22 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