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.
- checksums.yaml +4 -4
- data/README.md +7 -7
- data/docs/design.md +21 -7
- data/docs/environment.md +37 -15
- data/docs/usage.md +18 -17
- data/lib/dratools/accession_resolver.rb +27 -0
- data/lib/dratools/accession_resource_type_classifier.rb +2 -2
- data/lib/dratools/command_line_interface.rb +22 -11
- data/lib/dratools/commands/meta_command.rb +2 -2
- data/lib/dratools/commands/runs_command.rb +1 -25
- data/lib/dratools/commands/size_command.rb +10 -8
- data/lib/dratools/commands/url_command.rb +8 -7
- data/lib/dratools/config.rb +18 -4
- data/lib/dratools/ddbj_record_fields.rb +1 -6
- data/lib/dratools/ddbj_resource_client.rb +109 -22
- data/lib/dratools/download_candidate_builder.rb +4 -31
- data/lib/dratools/external_command_runner.rb +95 -12
- data/lib/dratools/progress_reporter.rb +87 -0
- data/lib/dratools/run_record_collector.rb +73 -26
- data/lib/dratools/version.rb +1 -1
- data/lib/dratools.rb +1 -0
- metadata +2 -1
|
@@ -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
|
|
14
|
+
# DDBJ Search API を呼び出す薄い HTTP クライアント。
|
|
13
15
|
class DdbjResourceClient
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 レコードの
|
|
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
|
-
|
|
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
|
|
28
|
-
ddbj_record.fetch(DdbjRecordFields::DISTRIBUTION_KEY, [])
|
|
27
|
+
Array(ddbj_record[DdbjRecordFields::DISTRIBUTION_KEY])
|
|
29
28
|
end
|
|
30
29
|
|
|
31
|
-
def
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
84
|
+
tool = available_command || raise(CommandError, command_not_found_message)
|
|
53
85
|
# 巨大ファイルを落とさないよう、短時間・最小範囲の確認に留める。
|
|
54
86
|
command =
|
|
55
|
-
|
|
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
|
-
|
|
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,
|
|
110
|
+
tool = available_command || raise(CommandError, command_not_found_message)
|
|
67
111
|
command =
|
|
68
|
-
|
|
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
|
-
|
|
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}=#{
|
|
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
|
-
#
|
|
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 =
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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 (
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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]
|
|
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
|