dratools 0.0.1 → 0.0.3

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.
@@ -7,12 +7,18 @@ require 'uri'
7
7
 
8
8
  require_relative 'errors'
9
9
  require_relative 'version'
10
+ require_relative 'ddbj_record_fields'
11
+ require_relative 'progress_reporter'
10
12
 
11
13
  module Dratools
12
- # DDBJ resource API を呼び出す薄い HTTP クライアント。
14
+ # DDBJ Search API を呼び出す薄い HTTP クライアント。
13
15
  class DdbjResourceClient
14
- DDBJ_RESOURCE_BASE_URL = 'https://ddbj.nig.ac.jp/resource'
15
- RESOURCE_RECORD_EXTENSION = '.json'
16
+ DDBJ_SEARCH_API_BASE_URL = 'https://ddbj.nig.ac.jp/search/api'
17
+ ENTRIES_PATH = 'entries'
18
+ DBLINK_PATH = 'dblink'
19
+ ENTRY_RECORD_EXTENSION = '.json'
20
+ BULK_MAX_IDS = 1000
21
+ DBLINK_COUNTS_MAX_ITEMS = 100
16
22
  HTTPS_SCHEME = 'https'
17
23
  HTTP_LOCATION_HEADER = 'location'
18
24
  USER_AGENT_HEADER = 'User-Agent'
@@ -20,40 +26,106 @@ module Dratools
20
26
  DEFAULT_OPEN_TIMEOUT_SECONDS = 10
21
27
  DEFAULT_READ_TIMEOUT_SECONDS = 30
22
28
 
23
- def initialize(base_url: DDBJ_RESOURCE_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT_SECONDS,
24
- read_timeout: DEFAULT_READ_TIMEOUT_SECONDS)
29
+ def initialize(base_url: DDBJ_SEARCH_API_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT_SECONDS,
30
+ read_timeout: DEFAULT_READ_TIMEOUT_SECONDS, progress: ProgressReporter.new)
25
31
  @base_url = base_url.delete_suffix('/')
26
32
  @open_timeout = open_timeout
27
33
  @read_timeout = read_timeout
34
+ @progress = progress
28
35
  end
29
36
 
30
37
  def fetch_resource_record(type, accession)
31
- fetch_json("#{@base_url}/#{type}/#{accession}#{RESOURCE_RECORD_EXTENSION}")
38
+ @progress.report("fetching #{type} #{accession}")
39
+ fetch_json("#{@base_url}/#{ENTRIES_PATH}/#{type}/#{accession}#{ENTRY_RECORD_EXTENSION}")
40
+ end
41
+
42
+ def fetch_db_links(type, accession, target: nil)
43
+ @progress.report("linking #{type} #{accession}")
44
+ request_uri = URI("#{@base_url}/#{DBLINK_PATH}/#{type}/#{accession}")
45
+ request_uri.query = URI.encode_www_form(target: target) if target
46
+ fetch_json(request_uri.to_s).fetch(DdbjRecordFields::DB_XREFS_KEY, [])
47
+ end
48
+
49
+ def fetch_resource_records_bulk(type, accessions, include_db_xrefs: false)
50
+ accessions.each_slice(BULK_MAX_IDS).with_object({}) do |chunk, records|
51
+ records.merge!(
52
+ fetch_resource_records_bulk_chunk(type, chunk, include_db_xrefs: include_db_xrefs)
53
+ )
54
+ end
55
+ end
56
+
57
+ def fetch_db_link_counts(items)
58
+ items.each_slice(DBLINK_COUNTS_MAX_ITEMS).with_object({}) do |chunk, counts|
59
+ counts.merge!(fetch_db_link_counts_chunk(chunk))
60
+ end
32
61
  end
33
62
 
34
63
  private
35
64
 
65
+ def fetch_db_link_counts_chunk(items)
66
+ @progress.report("counting links (#{items.length})")
67
+ request_url = "#{@base_url}/#{DBLINK_PATH}/counts"
68
+ payload = post_json(request_url, items: items)
69
+ payload.fetch('items', []).to_h do |item|
70
+ [[item['type'], item[DdbjRecordFields::IDENTIFIER_KEY]], item.fetch('counts', {})]
71
+ end
72
+ end
73
+
74
+ def fetch_resource_records_bulk_chunk(type, accessions, include_db_xrefs:)
75
+ @progress.report("fetching #{type} bulk (#{accessions.length})")
76
+ request_uri = URI("#{@base_url}/#{ENTRIES_PATH}/#{type}/bulk")
77
+ request_uri.query = URI.encode_www_form(includeDbXrefs: include_db_xrefs)
78
+ payload = post_json(request_uri.to_s, ids: accessions)
79
+ payload.fetch('entries', []).to_h do |record|
80
+ accession = record[DdbjRecordFields::IDENTIFIER_KEY] ||
81
+ record[DdbjRecordFields::ACCESSION_KEY] ||
82
+ record[DdbjRecordFields::ID_KEY] ||
83
+ record[DdbjRecordFields::PRIMARY_ID_KEY]
84
+ [accession, record]
85
+ end
86
+ end
87
+
36
88
  def fetch_json(request_url, redirects_remaining = DEFAULT_REDIRECT_LIMIT)
37
- request_uri = URI(request_url)
38
- response = get_http_response(request_uri)
39
-
40
- case response
41
- when Net::HTTPSuccess
42
- JSON.parse(response.body)
43
- when Net::HTTPRedirection
44
- raise NetworkError, "too many redirects: #{request_url}" if redirects_remaining <= 0
45
-
46
- location = response[HTTP_LOCATION_HEADER]
47
- raise NetworkError, "redirect without location: #{request_url}" if location.to_s.empty?
48
-
49
- fetch_json(URI.join(request_uri, location).to_s, redirects_remaining - 1)
50
- when Net::HTTPNotFound
51
- raise NotFoundError, "not found: #{request_url}"
52
- else
89
+ with_network_errors(request_url) do
90
+ request_uri = URI(request_url)
91
+ response = get_http_response(request_uri)
92
+
93
+ case response
94
+ when Net::HTTPSuccess
95
+ parse_json_response(response, request_url)
96
+ when Net::HTTPRedirection
97
+ raise NetworkError, "too many redirects: #{request_url}" if redirects_remaining <= 0
98
+
99
+ location = response[HTTP_LOCATION_HEADER]
100
+ raise NetworkError, "redirect without location: #{request_url}" if location.to_s.empty?
101
+
102
+ fetch_json(URI.join(request_uri, location).to_s, redirects_remaining - 1)
103
+ when Net::HTTPNotFound
104
+ raise NotFoundError, "not found: #{request_url}"
105
+ else
106
+ raise NetworkError, "HTTP #{response.code}: #{request_url}"
107
+ end
108
+ end
109
+ end
110
+
111
+ def post_json(request_url, payload)
112
+ with_network_errors(request_url) do
113
+ response = post_http_response(URI(request_url), payload)
114
+ return parse_json_response(response, request_url) if response.is_a?(Net::HTTPSuccess)
115
+ raise NotFoundError, "not found: #{request_url}" if response.is_a?(Net::HTTPNotFound)
116
+
53
117
  raise NetworkError, "HTTP #{response.code}: #{request_url}"
54
118
  end
119
+ end
120
+
121
+ def parse_json_response(response, request_url)
122
+ JSON.parse(response.body)
55
123
  rescue JSON::ParserError => error
56
124
  raise NetworkError, "invalid JSON from #{request_url}: #{error.message}", cause: error
125
+ end
126
+
127
+ def with_network_errors(request_url)
128
+ yield
57
129
  rescue Timeout::Error, IOError, SocketError, SystemCallError => error
58
130
  message = "failed to fetch #{request_url}: #{error.class}: #{error.message}"
59
131
  raise NetworkError, message, cause: error
@@ -71,6 +143,21 @@ module Dratools
71
143
  end
72
144
  end
73
145
 
146
+ def post_http_response(request_uri, payload)
147
+ Net::HTTP.start(
148
+ request_uri.host,
149
+ request_uri.port,
150
+ use_ssl: request_uri.scheme == HTTPS_SCHEME,
151
+ open_timeout: @open_timeout,
152
+ read_timeout: @read_timeout
153
+ ) do |http|
154
+ request = Net::HTTP::Post.new(request_uri.request_uri, USER_AGENT_HEADER => user_agent)
155
+ request['Content-Type'] = 'application/json'
156
+ request.body = JSON.generate(payload)
157
+ http.request(request)
158
+ end
159
+ end
160
+
74
161
  def user_agent
75
162
  "#{NAME}/#{VERSION}"
76
163
  end
@@ -4,12 +4,12 @@ require_relative 'ddbj_record_fields'
4
4
  require_relative 'download_candidate'
5
5
 
6
6
  module Dratools
7
- # DDBJ run レコードの downloadUrl/distribution から DownloadCandidate を作る。
7
+ # DDBJ run レコードの distribution から DownloadCandidate を作る。
8
8
  class DownloadCandidateBuilder
9
9
  def build_from_run_record(run_record)
10
10
  run_accession = run_accession_from(run_record)
11
11
  downloads = download_items_from(run_record).filter_map do |download_item|
12
- build_from_download_item(run_accession, download_item)
12
+ build_from_distribution_item(run_accession, download_item)
13
13
  end
14
14
  downloads.uniq { |download| download_key(download) }
15
15
  end
@@ -24,21 +24,12 @@ module Dratools
24
24
  end
25
25
 
26
26
  def download_items_from(ddbj_record)
27
- ddbj_record.fetch(DdbjRecordFields::DOWNLOAD_URL_KEY, []) +
28
- ddbj_record.fetch(DdbjRecordFields::DISTRIBUTION_KEY, [])
27
+ Array(ddbj_record[DdbjRecordFields::DISTRIBUTION_KEY])
29
28
  end
30
29
 
31
- def build_from_download_item(run_accession, download_item)
30
+ def build_from_distribution_item(run_accession, download_item)
32
31
  return unless download_item.is_a?(Hash)
33
32
 
34
- if download_item[DdbjRecordFields::CONTENT_URL_KEY]
35
- build_from_distribution_item(run_accession, download_item)
36
- else
37
- build_from_download_url_item(run_accession, download_item)
38
- end
39
- end
40
-
41
- def build_from_distribution_item(run_accession, download_item)
42
33
  file_type = file_type_from_distribution(download_item)
43
34
  return unless file_type
44
35
 
@@ -52,28 +43,10 @@ module Dratools
52
43
  )
53
44
  end
54
45
 
55
- def build_from_download_url_item(run_accession, download_item)
56
- file_type = file_type_from_download_url(download_item)
57
- return unless file_type
58
-
59
- DownloadCandidate.new(
60
- run_accession: run_accession,
61
- type: file_type,
62
- url: download_item[DdbjRecordFields::URL_KEY],
63
- ftp_url: download_item[DdbjRecordFields::FTP_URL_KEY],
64
- size: download_item[DdbjRecordFields::SIZE_KEY] || download_item[DdbjRecordFields::FILE_SIZE_KEY],
65
- md5: download_item[DdbjRecordFields::MD5_KEY] || download_item[DdbjRecordFields::MD5_SUM_KEY]
66
- )
67
- end
68
-
69
46
  def file_type_from_distribution(download_item)
70
47
  file_type_from(download_item[DdbjRecordFields::ENCODING_FORMAT_KEY])
71
48
  end
72
49
 
73
- def file_type_from_download_url(download_item)
74
- file_type_from(download_item[DdbjRecordFields::TYPE_KEY])
75
- end
76
-
77
50
  def file_type_from(value)
78
51
  case value.to_s.downcase
79
52
  when DdbjRecordFields::FILE_TYPE_SRA
@@ -8,77 +8,146 @@ require_relative 'config'
8
8
  require_relative 'errors'
9
9
 
10
10
  module Dratools
11
- # curl wget を使って URL の確認とダウンロードを行うラッパー。
11
+ # curl, wget, aria2c のいずれかを使って URL の確認とダウンロードを行うラッパー。
12
12
  #
13
13
  # probe は短時間・無出力で済ませ、download は外部コマンドの進捗を端末へ流す。
14
14
  # 巨大ファイルを扱うため、download には総時間制限ではなく失速検知を使う。
15
15
  class ExternalCommandRunner
16
16
  CURL_COMMAND = 'curl'
17
17
  WGET_COMMAND = 'wget'
18
- SUPPORTED_COMMANDS = [CURL_COMMAND, WGET_COMMAND].freeze
18
+ ARIA2_COMMAND = 'aria2c'
19
+ AUTO_COMMANDS = [CURL_COMMAND, WGET_COMMAND].freeze
20
+ SUPPORTED_COMMANDS = [*AUTO_COMMANDS, ARIA2_COMMAND].freeze
21
+
19
22
  COMMAND_NOT_FOUND_MESSAGE = 'curl または wget が見つかりません'
23
+ PREFERRED_COMMAND_NOT_FOUND_MESSAGE = '指定されたダウンロードコマンドが見つかりません'
24
+ UNSUPPORTED_COMMAND_MESSAGE = '未対応のダウンロードコマンドです'
20
25
  DEFAULT_PROBE_TIMEOUT_SECONDS = 5
21
26
  PROBE_BYTE_RANGE = '0-0'
22
27
  SINGLE_ATTEMPT_COUNT = 1
23
28
 
29
+ # リダイレクト、HTTP エラー、静かな probe、範囲取得。
24
30
  CURL_PROBE_OPTIONS = ['--location', '--fail', '--silent', '--show-error', '--range'].freeze
31
+ # probe 全体のタイムアウト。
25
32
  CURL_TIMEOUT_OPTION = '--max-time'
26
33
  CURL_CONNECT_TIMEOUT_OPTION = '--connect-timeout'
34
+ # 失速判定に使う最低転送速度。
27
35
  CURL_SPEED_LIMIT_OPTION = '--speed-limit'
36
+ # 最低速度を下回ってよい秒数。
28
37
  CURL_SPEED_TIME_OPTION = '--speed-time'
29
38
  CURL_RETRY_OPTION = '--retry'
30
39
  CURL_OUTPUT_OPTION = '--output'
40
+ # 部分ファイルがあれば続きから再開。
31
41
  CURL_DOWNLOAD_OPTIONS = ['--location', '--fail', '--continue-at', '-'].freeze
32
42
 
43
+ # probe でファイルを保存しない。
33
44
  WGET_PROBE_OPTIONS = ['--spider'].freeze
45
+ # probe 全体のタイムアウト。
34
46
  WGET_TIMEOUT_OPTION = '--timeout'
35
47
  WGET_CONNECT_TIMEOUT_OPTION = '--connect-timeout'
48
+ # wget で失速検知に近い意味で使う。
36
49
  WGET_READ_TIMEOUT_OPTION = '--read-timeout'
50
+ # probe では 1 回だけにする。
37
51
  WGET_TRIES_OPTION = '--tries'
38
52
  WGET_WAITRETRY_OPTION = '--waitretry'
53
+ # 部分ファイルがあれば続きから再開。
39
54
  WGET_CONTINUE_OPTION = '--continue'
40
55
  WGET_OUTPUT_OPTION = '--output-document'
41
56
 
42
- def initialize(preferred: nil)
57
+ # probe で保存せず、通常出力も抑える。
58
+ ARIA2_PROBE_OPTIONS = ['--dry-run=true', '--quiet=true'].freeze
59
+ ARIA2_CONNECT_TIMEOUT_OPTION = '--connect-timeout'
60
+ # aria2c で失速検知に近い意味で使う。
61
+ ARIA2_TIMEOUT_OPTION = '--timeout'
62
+ # curl の --speed-limit に相当する最低転送速度。
63
+ ARIA2_LOWEST_SPEED_LIMIT_OPTION = '--lowest-speed-limit'
64
+ ARIA2_MAX_TRIES_OPTION = '--max-tries'
65
+ ARIA2_RETRY_WAIT_OPTION = '--retry-wait'
66
+ # 部分ファイルがあれば続きから再開。
67
+ ARIA2_CONTINUE_OPTION = '--continue=true'
68
+ # aria2c は保存先をディレクトリとファイル名に分ける。
69
+ ARIA2_DIR_OPTION = '--dir'
70
+ ARIA2_OUT_OPTION = '--out'
71
+ # 既定では並列取得しない。
72
+ ARIA2_SINGLE_CONNECTION_OPTIONS = ['--split=1', '--max-connection-per-server=1'].freeze
73
+
74
+ def initialize(preferred: Config.download_command)
43
75
  @preferred = preferred
44
76
  end
45
77
 
46
78
  def available_command
47
- candidates = [@preferred, *SUPPORTED_COMMANDS].compact
79
+ candidates = @preferred ? [@preferred] : AUTO_COMMANDS
48
80
  candidates.find { |command_name| executable_command?(command_name) }
49
81
  end
50
82
 
51
83
  def probe_url(url, timeout: DEFAULT_PROBE_TIMEOUT_SECONDS)
52
- tool = available_command || raise(CommandError, COMMAND_NOT_FOUND_MESSAGE)
84
+ tool = available_command || raise(CommandError, command_not_found_message)
53
85
  # 巨大ファイルを落とさないよう、短時間・最小範囲の確認に留める。
54
86
  command =
55
- if File.basename(tool) == CURL_COMMAND
87
+ case File.basename(tool)
88
+ when CURL_COMMAND
89
+ # 例: curl --location --fail --silent --show-error --range 0-0
90
+ # --max-time 5 --output /dev/null URL
56
91
  [tool, *CURL_PROBE_OPTIONS, PROBE_BYTE_RANGE, CURL_TIMEOUT_OPTION, timeout.to_s,
57
92
  CURL_OUTPUT_OPTION, null_device, url]
58
- else
93
+ when WGET_COMMAND
94
+ # 例: wget --spider --timeout=5 --tries=1 URL
59
95
  [tool, *WGET_PROBE_OPTIONS, "#{WGET_TIMEOUT_OPTION}=#{timeout}",
60
96
  "#{WGET_TRIES_OPTION}=#{SINGLE_ATTEMPT_COUNT}", url]
97
+ when ARIA2_COMMAND
98
+ # 例: aria2c --dry-run=true --quiet=true --connect-timeout=5 --timeout=5 --max-tries=1 URL
99
+ [tool, *ARIA2_PROBE_OPTIONS,
100
+ "#{ARIA2_CONNECT_TIMEOUT_OPTION}=#{timeout}",
101
+ "#{ARIA2_TIMEOUT_OPTION}=#{timeout}",
102
+ "#{ARIA2_MAX_TRIES_OPTION}=#{SINGLE_ATTEMPT_COUNT}", url]
103
+ else
104
+ unsupported_command!(tool)
61
105
  end
62
106
  run_quietly(command)
63
107
  end
64
108
 
65
109
  def download_url(url, output_path)
66
- tool = available_command || raise(CommandError, COMMAND_NOT_FOUND_MESSAGE)
110
+ tool = available_command || raise(CommandError, command_not_found_message)
67
111
  command =
68
- if File.basename(tool) == CURL_COMMAND
112
+ case File.basename(tool)
113
+ when CURL_COMMAND
114
+ # curl の低速検知は「指定秒数のあいだ指定速度を下回ったら失敗」。
115
+ # ネットワークが完全に切れず低速で固まるケースを、総時間制限なしで検出する。
116
+ # 例: curl --location --fail --continue-at - --connect-timeout 30
117
+ # --speed-limit 1024 --speed-time 60 --retry 3 --output OUT URL
69
118
  [tool, *CURL_DOWNLOAD_OPTIONS,
70
119
  CURL_CONNECT_TIMEOUT_OPTION, Config.download_connect_timeout_seconds.to_s,
71
120
  CURL_SPEED_LIMIT_OPTION, Config.download_stall_speed_bytes_per_second.to_s,
72
121
  CURL_SPEED_TIME_OPTION, Config.download_stall_timeout_seconds.to_s,
73
122
  CURL_RETRY_OPTION, Config.download_retry_count.to_s,
74
123
  CURL_OUTPUT_OPTION, output_path, url]
75
- else
124
+ when WGET_COMMAND
125
+ # wget では --read-timeout を失速検知に近い意味で使う。
126
+ # --continue は部分ファイルの続きから再開し、--output-document は保存先を固定する。
127
+ # 例: wget --continue --connect-timeout=30 --read-timeout=60
128
+ # --tries=4 --waitretry=5 --output-document OUT URL
76
129
  [tool, WGET_CONTINUE_OPTION,
77
130
  "#{WGET_CONNECT_TIMEOUT_OPTION}=#{Config.download_connect_timeout_seconds}",
78
131
  "#{WGET_READ_TIMEOUT_OPTION}=#{Config.download_stall_timeout_seconds}",
79
- "#{WGET_TRIES_OPTION}=#{Config.download_retry_count}",
132
+ "#{WGET_TRIES_OPTION}=#{download_attempt_count}",
80
133
  "#{WGET_WAITRETRY_OPTION}=#{Config.download_retry_wait_seconds}",
81
134
  WGET_OUTPUT_OPTION, output_path, url]
135
+ when ARIA2_COMMAND
136
+ # aria2c は保存先をディレクトリとファイル名に分けて指定する。
137
+ # --continue=true は部分ファイルがあれば続きから再開する。
138
+ # 例: aria2c --continue=true --split=1 --max-connection-per-server=1
139
+ # --connect-timeout=30 --timeout=60 --lowest-speed-limit=1024
140
+ # --max-tries=4 --retry-wait=5 --dir DIR --out FILE URL
141
+ [tool, ARIA2_CONTINUE_OPTION, *ARIA2_SINGLE_CONNECTION_OPTIONS,
142
+ "#{ARIA2_CONNECT_TIMEOUT_OPTION}=#{Config.download_connect_timeout_seconds}",
143
+ "#{ARIA2_TIMEOUT_OPTION}=#{Config.download_stall_timeout_seconds}",
144
+ "#{ARIA2_LOWEST_SPEED_LIMIT_OPTION}=#{Config.download_stall_speed_bytes_per_second}",
145
+ "#{ARIA2_MAX_TRIES_OPTION}=#{download_attempt_count}",
146
+ "#{ARIA2_RETRY_WAIT_OPTION}=#{Config.download_retry_wait_seconds}",
147
+ "#{ARIA2_DIR_OPTION}=#{File.dirname(output_path)}",
148
+ "#{ARIA2_OUT_OPTION}=#{File.basename(output_path)}", url]
149
+ else
150
+ unsupported_command!(tool)
82
151
  end
83
152
  run_streaming(command)
84
153
  end
@@ -93,7 +162,7 @@ module Dratools
93
162
  end
94
163
 
95
164
  def run_streaming(command)
96
- # 配列形式で渡すことでシェルを介さず、curl/wget stderr 進捗はそのまま見せる。
165
+ # 配列形式で渡すことでシェルを介さず、外部コマンドの stderr 進捗はそのまま見せる。
97
166
  return true if system(*command)
98
167
 
99
168
  status = $CHILD_STATUS
@@ -108,6 +177,20 @@ module Dratools
108
177
  end
109
178
  end
110
179
 
180
+ def command_not_found_message
181
+ return "#{PREFERRED_COMMAND_NOT_FOUND_MESSAGE}: #{@preferred}" if @preferred
182
+
183
+ COMMAND_NOT_FOUND_MESSAGE
184
+ end
185
+
186
+ def download_attempt_count
187
+ Config.download_retry_count + 1
188
+ end
189
+
190
+ def unsupported_command!(tool)
191
+ raise CommandError, "#{UNSUPPORTED_COMMAND_MESSAGE}: #{tool}"
192
+ end
193
+
111
194
  def null_device
112
195
  File::NULL
113
196
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dratools
4
+ # 対話的な端末のときだけ stderr へ一行進捗を表示する軽量レポーター。
5
+ # データは stdout に出るため、stdout をパイプしても進捗は混ざらない。
6
+ # 非 TTY(リダイレクト・パイプ・CI)では制御文字を出さず完全に無音にする。
7
+ class ProgressReporter
8
+ CLEAR_LINE = "\r\e[K"
9
+
10
+ # 通常の出力の直前に、表示中の進捗行を消す IO ラッパー。
11
+ class ClearingIO
12
+ def initialize(io, reporter)
13
+ @io = io
14
+ @reporter = reporter
15
+ end
16
+
17
+ def puts(*args)
18
+ @reporter.finish
19
+ @io.puts(*args)
20
+ end
21
+
22
+ def print(*args)
23
+ @reporter.finish
24
+ @io.print(*args)
25
+ end
26
+
27
+ def write(*args)
28
+ @reporter.finish
29
+ @io.write(*args)
30
+ end
31
+
32
+ def flush
33
+ @io.flush
34
+ end
35
+
36
+ def tty?
37
+ @io.respond_to?(:tty?) && @io.tty?
38
+ end
39
+
40
+ def method_missing(name, ...)
41
+ return super unless @io.respond_to?(name)
42
+
43
+ @io.public_send(name, ...)
44
+ end
45
+
46
+ def respond_to_missing?(name, include_private = false)
47
+ @io.respond_to?(name, include_private) || super
48
+ end
49
+ end
50
+
51
+ def initialize(io: $stderr, enabled: nil)
52
+ @io = io
53
+ @enabled = enabled.nil? ? interactive?(io) : enabled
54
+ @count = 0
55
+ @active = false
56
+ end
57
+
58
+ def clearing_io(io = @io)
59
+ ClearingIO.new(io, self)
60
+ end
61
+
62
+ # 1 件の進捗を表示する。直前の行を消してから上書きする。
63
+ def report(label)
64
+ return unless @enabled
65
+
66
+ @count += 1
67
+ @io.print("#{CLEAR_LINE}#{label} (#{@count})")
68
+ @io.flush
69
+ @active = true
70
+ end
71
+
72
+ # 残っている進捗行を消す。コマンド終了時(成功・失敗どちらでも)に呼ぶ。
73
+ def finish
74
+ return unless @active
75
+
76
+ @io.print(CLEAR_LINE)
77
+ @io.flush
78
+ @active = false
79
+ end
80
+
81
+ private
82
+
83
+ def interactive?(io)
84
+ io.respond_to?(:tty?) && io.tty?
85
+ end
86
+ end
87
+ end
@@ -10,7 +10,6 @@ require_relative 'traversal_node'
10
10
  module Dratools
11
11
  # BioProject などの上位レコードから DDBJ sra-run レコードを集める。
12
12
  class RunRecordCollector
13
- XREF_URL_PATTERN = %r{/(?:resource|search/entry)/([^/]+)/([^/?#.]+)}
14
13
  TRAVERSABLE_XREF_TYPES = [
15
14
  DdbjRecordFields::SRA_RUN_RESOURCE_TYPE,
16
15
  DdbjRecordFields::SRA_EXPERIMENT_RESOURCE_TYPE,
@@ -40,14 +39,26 @@ module Dratools
40
39
  return node
41
40
  end
42
41
 
43
- direct_children = explore_edges(run_xrefs, TraversalNode::DB_XREF_RELATION, seen_keys,
44
- tolerant: tolerant,
45
- direct_run_fetch_limit: direct_run_fetch_limit)
42
+ direct_children = explore_run_xrefs(
43
+ run_xrefs,
44
+ seen_keys,
45
+ tolerant: tolerant,
46
+ direct_run_fetch_limit: direct_run_fetch_limit
47
+ )
46
48
  if direct_children.any? { |child| child.run? || child.run_records.any? }
47
49
  node.children.concat(direct_children)
48
50
  return node
49
51
  end
50
52
 
53
+ node.children.concat(
54
+ recursive_children(ddbj_record, xrefs, seen_keys, tolerant, direct_run_fetch_limit)
55
+ )
56
+ node
57
+ end
58
+
59
+ private
60
+
61
+ def recursive_children(ddbj_record, xrefs, seen_keys, tolerant, direct_run_fetch_limit)
51
62
  recursive_xrefs = xrefs.select { |xref| traversable_xref?(xref) }
52
63
  validate_recursive_non_run_xref_count!(ddbj_record, recursive_xrefs)
53
64
  db_xref_edges = explore_edges(
@@ -64,12 +75,9 @@ module Dratools
64
75
  tolerant: tolerant,
65
76
  direct_run_fetch_limit: direct_run_fetch_limit
66
77
  )
67
- node.children.concat(db_xref_edges + child_edges)
68
- node
78
+ db_xref_edges + child_edges
69
79
  end
70
80
 
71
- private
72
-
73
81
  def lightweight_direct_run_nodes(run_xrefs, direct_run_fetch_limit)
74
82
  return nil unless direct_run_fetch_limit && run_xrefs.length > direct_run_fetch_limit
75
83
 
@@ -86,7 +94,8 @@ module Dratools
86
94
  accession = record_accession(ddbj_record) || 'record'
87
95
  raise InvalidRecordError,
88
96
  "#{accession} has #{non_run_xrefs.length} linked non-run records; " \
89
- 'refine to an experiment/sample accession before run expansion'
97
+ 'refine to an experiment/sample accession before run expansion, ' \
98
+ "or set #{Config::MAX_RECURSIVE_NON_RUN_XREFS_ENV}=unlimited"
90
99
  end
91
100
 
92
101
  def child_bioprojects(ddbj_record)
@@ -111,6 +120,53 @@ module Dratools
111
120
  end
112
121
  end
113
122
 
123
+ def explore_run_xrefs(run_xrefs, seen_keys, tolerant:, direct_run_fetch_limit:)
124
+ fetchable_xrefs = unseen_fetchable_xrefs(run_xrefs, seen_keys)
125
+ return [] if fetchable_xrefs.empty?
126
+
127
+ accessions = fetchable_xrefs.map { |xref| xref_accession(xref) }
128
+ records = @client.fetch_resource_records_bulk(
129
+ DdbjRecordFields::SRA_RUN_RESOURCE_TYPE,
130
+ accessions,
131
+ include_db_xrefs: false
132
+ )
133
+ fetchable_xrefs.map do |xref|
134
+ accession = xref_accession(xref)
135
+ if (record = records[accession])
136
+ explore(
137
+ record,
138
+ seen_keys: seen_keys,
139
+ relation: TraversalNode::DB_XREF_RELATION,
140
+ tolerant: tolerant,
141
+ direct_run_fetch_limit: direct_run_fetch_limit
142
+ )
143
+ elsif tolerant
144
+ node_from_xref(
145
+ xref,
146
+ relation: TraversalNode::DB_XREF_RELATION,
147
+ error: "not found: #{accession}"
148
+ )
149
+ else
150
+ raise NotFoundError, "not found: sra-run/#{accession}"
151
+ end
152
+ end
153
+ end
154
+
155
+ def unseen_fetchable_xrefs(xrefs, seen_keys)
156
+ xrefs.each_with_object([]) do |xref, selected|
157
+ next unless traversable_xref?(xref)
158
+
159
+ accession = xref_accession(xref)
160
+ next if accession.empty?
161
+
162
+ reference_key = xref_key(xref)
163
+ next if reference_key.empty? || seen_keys.include?(reference_key)
164
+
165
+ seen_keys.add(reference_key)
166
+ selected << xref
167
+ end
168
+ end
169
+
114
170
  def explore_xref(xref, relation, seen_keys, tolerant:, direct_run_fetch_limit:)
115
171
  linked_record = fetch_xref_record(xref)
116
172
  explore(
@@ -127,20 +183,17 @@ module Dratools
127
183
  end
128
184
 
129
185
  def xref_key(xref)
130
- reference_url = xref[DdbjRecordFields::URL_KEY].to_s
131
- return reference_url unless reference_url.empty?
186
+ xref_accession(xref)
187
+ end
132
188
 
189
+ def xref_accession(xref)
133
190
  (xref[DdbjRecordFields::ID_KEY] || xref[DdbjRecordFields::IDENTIFIER_KEY]).to_s
134
191
  end
135
192
 
136
193
  def fetch_xref_record(xref)
137
- if (resource_match = xref[DdbjRecordFields::URL_KEY].to_s.match(XREF_URL_PATTERN))
138
- @client.fetch_resource_record(resource_match[1], resource_match[2])
139
- elsif xref[DdbjRecordFields::ID_KEY] || xref[DdbjRecordFields::IDENTIFIER_KEY]
140
- fetch_xref_by_identifier(xref)
141
- else
142
- raise InvalidRecordError, 'sra-run xref has no URL or id'
143
- end
194
+ raise InvalidRecordError, 'xref has no identifier' if xref_accession(xref).empty?
195
+
196
+ fetch_xref_by_identifier(xref)
144
197
  end
145
198
 
146
199
  def fetch_xref_by_identifier(xref)
@@ -160,15 +213,13 @@ module Dratools
160
213
 
161
214
  def run_record?(ddbj_record)
162
215
  record_type = ddbj_record[DdbjRecordFields::TYPE_KEY]
163
- return record_type == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE if record_type
164
-
165
- ddbj_record[DdbjRecordFields::DOWNLOAD_URL_KEY].is_a?(Array)
216
+ record_type == DdbjRecordFields::SRA_RUN_RESOURCE_TYPE
166
217
  end
167
218
 
168
219
  def node_from_record(ddbj_record, relation:)
169
220
  TraversalNode.new(
170
221
  relation: relation,
171
- type: ddbj_record[DdbjRecordFields::TYPE_KEY] || inferred_record_type(ddbj_record),
222
+ type: ddbj_record[DdbjRecordFields::TYPE_KEY],
172
223
  accession: record_accession(ddbj_record),
173
224
  object_type: ddbj_record['objectType'],
174
225
  record: run_record?(ddbj_record) ? ddbj_record : nil
@@ -190,9 +241,5 @@ module Dratools
190
241
  ddbj_record[DdbjRecordFields::ID_KEY] ||
191
242
  ddbj_record[DdbjRecordFields::PRIMARY_ID_KEY]
192
243
  end
193
-
194
- def inferred_record_type(ddbj_record)
195
- DdbjRecordFields::SRA_RUN_RESOURCE_TYPE if run_record?(ddbj_record)
196
- end
197
244
  end
198
245
  end