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 +4 -4
- data/lib/miga/remote_dataset/base.rb +22 -5
- data/lib/miga/remote_dataset/download.rb +164 -23
- data/lib/miga/remote_dataset.rb +11 -5
- data/lib/miga/version.rb +2 -2
- data/test/remote_dataset_test.rb +296 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3befac28b6f332444a88067d1e04a8fe9f3d77e3800b6363be17fec49cee367
|
|
4
|
+
data.tar.gz: a68987776138c7752183a4eadc14fe42ca95eb971b721bf41830f16548bf859e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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,
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
##
|
data/lib/miga/remote_dataset.rb
CHANGED
|
@@ -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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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,
|
|
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,
|
|
23
|
+
VERSION_DATE = Date.new(2026, 11, 9)
|
|
24
24
|
|
|
25
25
|
##
|
|
26
26
|
# References of MiGA
|
data/test/remote_dataset_test.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
11
|
+
date: 2026-11-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: daemons
|