solvebio 1.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +7 -0
- data/.travis.yml +13 -0
- data/Gemfile +4 -0
- data/Gemspec +3 -0
- data/LICENSE +21 -0
- data/Makefile +17 -0
- data/README.md +64 -0
- data/Rakefile +59 -0
- data/bin/solvebio.rb +36 -0
- data/demo/README.md +14 -0
- data/demo/dataset/facets.rb +13 -0
- data/demo/dataset/field.rb +13 -0
- data/demo/depository/README.md +24 -0
- data/demo/depository/all.rb +13 -0
- data/demo/depository/retrieve.rb +13 -0
- data/demo/depository/versions-all.rb +13 -0
- data/demo/query/query-filter.rb +30 -0
- data/demo/query/query.rb +13 -0
- data/demo/query/range-filter.rb +18 -0
- data/demo/test-api.rb +98 -0
- data/lib/apiresource.rb +130 -0
- data/lib/cli/auth.rb +122 -0
- data/lib/cli/help.rb +13 -0
- data/lib/cli/irb.rb +58 -0
- data/lib/cli/irbrc.rb +53 -0
- data/lib/cli/options.rb +75 -0
- data/lib/client.rb +152 -0
- data/lib/credentials.rb +67 -0
- data/lib/errors.rb +81 -0
- data/lib/filter.rb +312 -0
- data/lib/help.rb +46 -0
- data/lib/locale.rb +47 -0
- data/lib/main.rb +37 -0
- data/lib/query.rb +415 -0
- data/lib/resource.rb +414 -0
- data/lib/solvebio.rb +14 -0
- data/lib/solveobject.rb +101 -0
- data/lib/tabulate.rb +706 -0
- data/solvebio.gemspec +75 -0
- data/test/data/netrc-save +6 -0
- data/test/helper.rb +3 -0
- data/test/test-auth.rb +54 -0
- data/test/test-client.rb +27 -0
- data/test/test-error.rb +36 -0
- data/test/test-filter.rb +70 -0
- data/test/test-netrc.rb +42 -0
- data/test/test-query-batch.rb +60 -0
- data/test/test-query-init.rb +29 -0
- data/test/test-query-paging.rb +123 -0
- data/test/test-query.rb +88 -0
- data/test/test-resource.rb +47 -0
- data/test/test-solveobject.rb +27 -0
- data/test/test-tabulate.rb +127 -0
- metadata +158 -0
data/lib/client.rb
ADDED
@@ -0,0 +1,152 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
require 'openssl'
|
4
|
+
require 'net/http'
|
5
|
+
require 'json'
|
6
|
+
require_relative 'credentials'
|
7
|
+
require_relative 'errors'
|
8
|
+
|
9
|
+
# import textwrap
|
10
|
+
|
11
|
+
# A requests-based HTTP client for SolveBio API resources
|
12
|
+
class SolveBio::Client
|
13
|
+
|
14
|
+
attr_reader :headers, :api_host
|
15
|
+
attr_accessor :api_key
|
16
|
+
|
17
|
+
def initialize(api_key=nil, api_host=nil)
|
18
|
+
@api_key = api_key || SolveBio::api_key
|
19
|
+
SolveBio::api_key ||= api_key
|
20
|
+
@api_host = api_host || SolveBio::API_HOST
|
21
|
+
# Mirroring comments from:
|
22
|
+
# http://ruby-doc.org/stdlib-2.1.2/libdoc/net/http/rdoc/Net/HTTP.html
|
23
|
+
# gzip compression is used in preference to deflate
|
24
|
+
# compression, which is used in preference to no compression.
|
25
|
+
@headers = {
|
26
|
+
'Content-Type' => 'application/json',
|
27
|
+
'Accept' => 'application/json',
|
28
|
+
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
|
29
|
+
'User-Agent' => 'SolveBio Ruby Client %s [%s/%s]' % [
|
30
|
+
SolveBio::VERSION,
|
31
|
+
SolveBio::RUBY_IMPLEMENTATION,
|
32
|
+
SolveBio::RUBY_VERSION
|
33
|
+
]
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
def request(method, url, params=nil, raw=false)
|
38
|
+
|
39
|
+
if not @api_host
|
40
|
+
raise SolveBio::Error.new(nil, 'No SolveBio API host is set')
|
41
|
+
elsif not url.start_with?(@api_host)
|
42
|
+
url = URI.join(@api_host, url).to_s
|
43
|
+
end
|
44
|
+
|
45
|
+
uri = URI.parse(url)
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
47
|
+
|
48
|
+
# Note: there's also read_timeout and ssl_timeout
|
49
|
+
http.open_timeout = 80 # in seconds
|
50
|
+
|
51
|
+
if uri.scheme == 'https'
|
52
|
+
http.use_ssl = true
|
53
|
+
# FIXME? Risky - see
|
54
|
+
# http://www.rubyinside.com/how-to-cure-nethttps-risky-default-https-behavior-4010.html
|
55
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
56
|
+
end
|
57
|
+
|
58
|
+
http.set_debug_output($stderr) if $DEBUG
|
59
|
+
SolveBio::logger.debug('API %s Request: %s' % [method.upcase, url])
|
60
|
+
|
61
|
+
request = nil
|
62
|
+
if ['POST', 'PUT', 'PATCH'].member?(method.upcase)
|
63
|
+
# FIXME? do we need to do something different for
|
64
|
+
# PUT and PATCH?
|
65
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
66
|
+
request.body = params.to_json
|
67
|
+
else
|
68
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
69
|
+
end
|
70
|
+
@headers.each { |k, v| request.add_field(k, v) }
|
71
|
+
request.add_field('Authorization', "Token #{@api_key}") if @api_key
|
72
|
+
response = http.request(request)
|
73
|
+
|
74
|
+
# FIXME: There's probably gzip decompression built in to
|
75
|
+
# net/http. Until I figure out how to get that to work, the
|
76
|
+
# below works.
|
77
|
+
case response
|
78
|
+
when Net::HTTPSuccess then
|
79
|
+
begin
|
80
|
+
if response['Content-Encoding'].eql?( 'gzip' ) then
|
81
|
+
puts "Performing gzip decompression for response body." if $DEBUG
|
82
|
+
sio = StringIO.new( response.body )
|
83
|
+
gz = Zlib::GzipReader.new( sio )
|
84
|
+
response.body = gz.read()
|
85
|
+
end
|
86
|
+
rescue Exception
|
87
|
+
puts "Error occurred (#{$!.message})" if $DEBUG
|
88
|
+
# handle errors
|
89
|
+
raise $!.message
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
status_code = response.code.to_i
|
94
|
+
if status_code < 200 or status_code >= 300
|
95
|
+
handle_api_error(response)
|
96
|
+
end
|
97
|
+
|
98
|
+
if raw
|
99
|
+
return response.body
|
100
|
+
else
|
101
|
+
return JSON.parse(response.body)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def handle_request_error(e)
|
106
|
+
# FIXME: go over this. It is still a rough translation
|
107
|
+
# from the python.
|
108
|
+
err = e.inspect
|
109
|
+
if e.kind_of?(requests.exceptions.RequestException)
|
110
|
+
msg = SolveBio::Error::Default_message
|
111
|
+
else
|
112
|
+
msg = 'Unexpected error communicating with SolveBio. ' +
|
113
|
+
"It looks like there's probably a configuration " +
|
114
|
+
'issue locally. If this problem persists, let us ' +
|
115
|
+
'know at contact@solvebio.com.'
|
116
|
+
end
|
117
|
+
msg = msg + "\n\n(Network error: #{err}"
|
118
|
+
raise SolveBio::Error.new(nil, msg)
|
119
|
+
end
|
120
|
+
|
121
|
+
def handle_api_error(response)
|
122
|
+
if [400, 401, 403, 404].member?(response.code.to_i)
|
123
|
+
raise SolveBio::Error.new(response)
|
124
|
+
else
|
125
|
+
SolveBio::logger.info("API Error: #{response.msg}")
|
126
|
+
raise SolveBio::Error.new(response)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def self.client
|
131
|
+
@@client ||= SolveBio::Client.new()
|
132
|
+
end
|
133
|
+
|
134
|
+
def self.request(*args)
|
135
|
+
client.request(*args)
|
136
|
+
end
|
137
|
+
|
138
|
+
end
|
139
|
+
|
140
|
+
if __FILE__ == $0
|
141
|
+
puts SolveBio::Client.client.headers
|
142
|
+
puts SolveBio::Client.client.api_host
|
143
|
+
client = SolveBio::Client.new(nil, 'http://google.com')
|
144
|
+
response = client.request('http', 'http://google.com') rescue 'no good'
|
145
|
+
puts response.inspect
|
146
|
+
puts '-' * 30
|
147
|
+
response = client.request('http', 'http://www.google.com') rescue 'nope'
|
148
|
+
puts response.inspect
|
149
|
+
puts '-' * 30
|
150
|
+
response = client.request('http', 'https://www.google.com') rescue 'nope'
|
151
|
+
puts response.inspect
|
152
|
+
end
|
data/lib/credentials.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
# Deals with reading netrc credentials
|
4
|
+
require_relative 'main'
|
5
|
+
require 'netrc'
|
6
|
+
require 'uri'
|
7
|
+
|
8
|
+
#
|
9
|
+
# Raised if the credentials are not found.
|
10
|
+
#
|
11
|
+
class CredentialsError < RuntimeError
|
12
|
+
end
|
13
|
+
|
14
|
+
module SolveBio::Credentials
|
15
|
+
|
16
|
+
module_function
|
17
|
+
|
18
|
+
# SolveBio API host -- just the hostname
|
19
|
+
def api_host
|
20
|
+
URI(SolveBio::API_HOST).host
|
21
|
+
end
|
22
|
+
|
23
|
+
def netrc_path
|
24
|
+
path =
|
25
|
+
if ENV['NETRC_PATH']
|
26
|
+
File.join(ENV['NETRC_PATH'], ".netrc")
|
27
|
+
else
|
28
|
+
Netrc.default_path
|
29
|
+
end
|
30
|
+
if not File.exist?(path)
|
31
|
+
raise IOError, "netrc file #{path} not found"
|
32
|
+
end
|
33
|
+
path
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
# Returns the tuple user / password given a path for the .netrc file.
|
38
|
+
# Raises CredentialsError if no valid netrc file is found.
|
39
|
+
#
|
40
|
+
def get_credentials
|
41
|
+
n = Netrc.read(netrc_path)
|
42
|
+
return n[api_host]
|
43
|
+
rescue Netrc::Error => e
|
44
|
+
raise CredentialsError, "Could not read .netrc file: #{e}"
|
45
|
+
end
|
46
|
+
module_function :get_credentials
|
47
|
+
|
48
|
+
def delete_credentials
|
49
|
+
n = Netrc.read(netrc_path)
|
50
|
+
n.delete(api_host)
|
51
|
+
n.save
|
52
|
+
end
|
53
|
+
|
54
|
+
def save_credentials(email, api_key)
|
55
|
+
n = Netrc.read(netrc_path)
|
56
|
+
# Overwrites any existing credentials
|
57
|
+
n[api_host] = email, api_key
|
58
|
+
n.save
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Demo code
|
63
|
+
if __FILE__ == $0
|
64
|
+
include SolveBio::Credentials
|
65
|
+
puts "authentication: #{netrc_path}"
|
66
|
+
puts "creds", get_credentials
|
67
|
+
end
|
data/lib/errors.rb
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
require_relative 'main'
|
4
|
+
|
5
|
+
class SolveBio::Error < RuntimeError
|
6
|
+
Default_message =
|
7
|
+
'Unexpected error communicating with SolveBio. ' +
|
8
|
+
'If this problem persists, let us know at ' +
|
9
|
+
'contact@solvebio.com.'
|
10
|
+
|
11
|
+
attr_reader :json_body
|
12
|
+
attr_reader :status_code
|
13
|
+
attr_reader :message
|
14
|
+
attr_reader :field_errors
|
15
|
+
|
16
|
+
def initialize( response=nil, message=nil)
|
17
|
+
@json_body = nil
|
18
|
+
@status_code = nil
|
19
|
+
@message = message or Default_message
|
20
|
+
@field_errors = []
|
21
|
+
|
22
|
+
if response
|
23
|
+
@status_code = response.code.to_i
|
24
|
+
@message = response.message
|
25
|
+
begin
|
26
|
+
@json_body = JSON.parse(response.body)
|
27
|
+
rescue
|
28
|
+
@message = '404 Not Found.' if @status_code == 404
|
29
|
+
SolveBio.logger.debug(
|
30
|
+
"API Response (%d): No content." % @status_code)
|
31
|
+
else
|
32
|
+
SolveBio.logger.debug(
|
33
|
+
"API Response (#{@status_code}): #{@json_body}")
|
34
|
+
|
35
|
+
if [400, 401, 403, 404].member?(@status_code)
|
36
|
+
@message = 'Bad request.'
|
37
|
+
|
38
|
+
if @json_body.member?('detail')
|
39
|
+
@message = '%s' % @json_body['detail']
|
40
|
+
end
|
41
|
+
|
42
|
+
if @json_body.member?('non_field_errors')
|
43
|
+
@message = '%s.' % \
|
44
|
+
@json_body['non_field_errors'].join(', ')
|
45
|
+
end
|
46
|
+
|
47
|
+
@json_body.each do |k, v|
|
48
|
+
unless ['detail', 'non_field_errors'].member?(k)
|
49
|
+
v = v.join(', ') if v.kind_of?(Array)
|
50
|
+
@field_errors << ('%s (%s)' % [k, v])
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
unless @field_errors.empty?
|
55
|
+
@message += (' The following fields were missing ' +
|
56
|
+
'or invalid: %s' %
|
57
|
+
@field_errors.join(', '))
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
self
|
63
|
+
end
|
64
|
+
|
65
|
+
def to_s
|
66
|
+
@message
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Demo code
|
71
|
+
if __FILE__ == $0
|
72
|
+
puts SolveBio::Error.new
|
73
|
+
puts SolveBio::Error.new(nil, 'Hi there').inspect
|
74
|
+
puts SolveBio::Error.new(nil, 'Hi there').to_s
|
75
|
+
puts SolveBio::Error.new(nil, ['Hello, ', 'again.']).inspect
|
76
|
+
|
77
|
+
require 'net/http'
|
78
|
+
response = Net::HTTPUnauthorized.new('HTTP 1.1', '404', 'No creds')
|
79
|
+
puts SolveBio::Error.new(response).to_s
|
80
|
+
|
81
|
+
end
|
data/lib/filter.rb
ADDED
@@ -0,0 +1,312 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
require_relative 'main'
|
3
|
+
|
4
|
+
# SolveBio::Filter objects.
|
5
|
+
#
|
6
|
+
# Makes it easier to create filters cumulatively using ``&`` (and),
|
7
|
+
# ``|`` (or) and ``~`` (not) operations.
|
8
|
+
#
|
9
|
+
# == Example
|
10
|
+
#
|
11
|
+
# require 'solvebio'
|
12
|
+
|
13
|
+
# f = SolveBio::Filter.new #=> <Filter []>
|
14
|
+
|
15
|
+
# f &= SolveBio::Filter.new :price => 'Free' #=> <Filter [[:price, "Free"]]>
|
16
|
+
|
17
|
+
# f |= SolveBio::Filter.new :style => 'Mexican' #=> <Filter [{:or=>[[:price, "Free"], [:style, "Mexican"]]}]>
|
18
|
+
#
|
19
|
+
# The final result is a filter that can be used in a query which match es
|
20
|
+
# "price = 'Free' or style = 'Mexican'".
|
21
|
+
#
|
22
|
+
# By default, each key/value pairs are AND'ed together. However, you can change that
|
23
|
+
# to OR by passing in +:or+ as the last argument.
|
24
|
+
#
|
25
|
+
# * `<field>='value` matches if the field is term filter (exact term)
|
26
|
+
# * `<field>__in=[<item1>, ...]` matches any of the terms <item1> and so on
|
27
|
+
# * `<field>__range=[<start>, <end>]` matches anything from <start> to <end>
|
28
|
+
# * `<field>__between=[<start>, <end>]` matches anything between <start> to <end> not include either <start> or <end>
|
29
|
+
#
|
30
|
+
# String terms are not analyzed and are always assumed to be exact matches.
|
31
|
+
#
|
32
|
+
# Numeric columns can be selected by range using:
|
33
|
+
#
|
34
|
+
# * `<field>__gt`: greater than
|
35
|
+
# * `<field>__gte`: greater than or equal to
|
36
|
+
# * `<field>__lt`: less than
|
37
|
+
# * `<field>__lte`: less than or equal to
|
38
|
+
#
|
39
|
+
# Field action examples:
|
40
|
+
#
|
41
|
+
# dataset.query(:gene__in => ['BRCA', 'GATA3'],
|
42
|
+
# :chr => '3',
|
43
|
+
# :start__gt => 10000,
|
44
|
+
# :end__lte => 20000)
|
45
|
+
|
46
|
+
class SolveBio::Filter
|
47
|
+
|
48
|
+
attr_accessor :filters
|
49
|
+
|
50
|
+
# Creates a new Filter, the first argument is expected to be Hash or an Array.
|
51
|
+
def initialize(filters={}, conn=:and)
|
52
|
+
if filters.kind_of?(Hash)
|
53
|
+
@filters = SolveBio::Filter.
|
54
|
+
normalize(filters.keys.sort.map{|key| [key, filters[key]]})
|
55
|
+
elsif filters.kind_of?(Array)
|
56
|
+
@filters = SolveBio::Filter.normalize(filters)
|
57
|
+
elsif filters.kind_of?(SolveBio::Filter)
|
58
|
+
@filters = SolveBio::Filter.deep_copy(filters.filters)
|
59
|
+
return self
|
60
|
+
else
|
61
|
+
raise TypeError, "Invalid filter type #{filters.class}"
|
62
|
+
end
|
63
|
+
@filters = [{conn => @filters}] if filters.size > 1
|
64
|
+
self
|
65
|
+
end
|
66
|
+
|
67
|
+
def inspect
|
68
|
+
return "<SolveBio::Filter #{@filters.inspect}>"
|
69
|
+
end
|
70
|
+
|
71
|
+
def empty?
|
72
|
+
@filters.empty?
|
73
|
+
end
|
74
|
+
|
75
|
+
# Deep copy
|
76
|
+
def clone
|
77
|
+
SolveBio::Filter.deep_copy(self)
|
78
|
+
end
|
79
|
+
|
80
|
+
# OR and AND will create a new Filter, with the filters from both Filter
|
81
|
+
# objects combined with the connector `conn`.
|
82
|
+
# FIXME: should we allow a default conn parameter?
|
83
|
+
def combine(other, conn=:and)
|
84
|
+
|
85
|
+
return other.clone if self.empty?
|
86
|
+
|
87
|
+
if other.empty?
|
88
|
+
return self.clone
|
89
|
+
elsif self.filters[0].member?(conn)
|
90
|
+
f = self.clone
|
91
|
+
f.filters[0][conn] += other.filters
|
92
|
+
elsif other.filters[0].member?(conn)
|
93
|
+
f = other.clone
|
94
|
+
f.filters[0][conn] += self.filters
|
95
|
+
else
|
96
|
+
f = initialize(self.clone.filters + other.filters, conn)
|
97
|
+
end
|
98
|
+
|
99
|
+
return f
|
100
|
+
end
|
101
|
+
|
102
|
+
def |(other)
|
103
|
+
return self.combine(other, :or)
|
104
|
+
end
|
105
|
+
|
106
|
+
def &(other)
|
107
|
+
return self.combine(other, :and)
|
108
|
+
end
|
109
|
+
|
110
|
+
def ~()
|
111
|
+
f = self.clone
|
112
|
+
|
113
|
+
# not of null filter is null fiter
|
114
|
+
return f if f.empty?
|
115
|
+
|
116
|
+
# length of self_filters should never be more than 1
|
117
|
+
filters = f.filters.first
|
118
|
+
if filters.kind_of?(Hash) and
|
119
|
+
filters.member?(:not)
|
120
|
+
# The filters are already a single dictionary
|
121
|
+
# containing a 'not'. Swap out the 'not'
|
122
|
+
f.filters = [filters[:not]]
|
123
|
+
else
|
124
|
+
# 'not' blocks can contain only dicts or a single tuple filter
|
125
|
+
# so we get the first element from the filter list
|
126
|
+
f.filters = [{:not => filters}]
|
127
|
+
end
|
128
|
+
|
129
|
+
return f
|
130
|
+
end
|
131
|
+
|
132
|
+
# Checks and normalizes filter array tuples
|
133
|
+
def self.normalize(ary)
|
134
|
+
ary.map do |tuple|
|
135
|
+
unless tuple.kind_of?(Array)
|
136
|
+
raise(TypeError,
|
137
|
+
"Invalid filter element #{tuple.class}; want Array")
|
138
|
+
end
|
139
|
+
unless tuple.size == 2
|
140
|
+
raise(TypeError,
|
141
|
+
"filter element size must be 2; is #{tuple.size}")
|
142
|
+
end
|
143
|
+
key, value = tuple
|
144
|
+
if key.to_s =~ /.+__(.+)$/
|
145
|
+
op = $1
|
146
|
+
unless %w(gt gte lt lte in range between).member?(op)
|
147
|
+
raise(TypeError,
|
148
|
+
"Invalid field operation #{op} in #{key}")
|
149
|
+
end
|
150
|
+
case op
|
151
|
+
when 'gt', 'gte', 'lt', 'lte'
|
152
|
+
begin
|
153
|
+
value = Float(value)
|
154
|
+
rescue
|
155
|
+
raise(TypeError,
|
156
|
+
"Invalid field value #{value} for #{key}; " +
|
157
|
+
"should be a number")
|
158
|
+
end
|
159
|
+
tuple = [key, value]
|
160
|
+
when 'range', 'between'
|
161
|
+
if value.kind_of?(Range)
|
162
|
+
value = [value.min, value.max]
|
163
|
+
end
|
164
|
+
unless value.kind_of?(Array)
|
165
|
+
raise(TypeError,
|
166
|
+
"Invalid field value #{value} for #{key}; " +
|
167
|
+
"should be an array")
|
168
|
+
end
|
169
|
+
unless value.size == 2
|
170
|
+
raise(TypeError,
|
171
|
+
"Invalid field value #{value} for #{key}; " +
|
172
|
+
"array should have exactly two values")
|
173
|
+
end
|
174
|
+
if value.first > value.last
|
175
|
+
raise(IndexError,
|
176
|
+
"Invalid field value #{value} for #{key}; " +
|
177
|
+
"start value not greater than end value")
|
178
|
+
end
|
179
|
+
|
180
|
+
# FIXME: Should we check that value contains only numbers?
|
181
|
+
tuple = [key, value]
|
182
|
+
when 'in'
|
183
|
+
unless value.kind_of?(Array)
|
184
|
+
raise(TypeError,
|
185
|
+
"Invalid field value #{value} for #{key}; " +
|
186
|
+
"should be an array")
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
end
|
191
|
+
tuple
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def self.deep_copy(obj)
|
196
|
+
Marshal.load(Marshal.dump(obj))
|
197
|
+
end
|
198
|
+
|
199
|
+
# Takes an Array of filter items and returns an Array that can be
|
200
|
+
# passed off (when converted to JSON) to a SolveBio client filter
|
201
|
+
# parameter. As such, the output format is highly dependent on
|
202
|
+
# the SolveBio API format.
|
203
|
+
#
|
204
|
+
# The filter items can be either a SolveBio::Filter, or Hash of
|
205
|
+
# the right form, or an Array of the right form.
|
206
|
+
def self.process_filters(filters)
|
207
|
+
rv = []
|
208
|
+
filters.each do |f|
|
209
|
+
if f.kind_of?(SolveBio::Filter)
|
210
|
+
if f.filters
|
211
|
+
rv << process_filters(f.filters)
|
212
|
+
next
|
213
|
+
end
|
214
|
+
elsif f.kind_of?(Hash)
|
215
|
+
key = f.keys[0]
|
216
|
+
val = f[key]
|
217
|
+
|
218
|
+
if val.kind_of?(Hash)
|
219
|
+
filter_filters = process_filters(val)
|
220
|
+
if filter_filters.size == 1
|
221
|
+
filter_filters = filter_filters[0]
|
222
|
+
end
|
223
|
+
rv << {key => filter_filters}
|
224
|
+
else
|
225
|
+
rv << {key => process_filters(val)}
|
226
|
+
end
|
227
|
+
elsif f.kind_of?(Array)
|
228
|
+
rv << f
|
229
|
+
else
|
230
|
+
raise TypeError, "Invalid filter class #{f.class}"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
return rv
|
234
|
+
end
|
235
|
+
|
236
|
+
|
237
|
+
end
|
238
|
+
|
239
|
+
# Helper class that generates Range Filters from UCSC-style ranges.
|
240
|
+
class SolveBio::RangeFilter < SolveBio::Filter
|
241
|
+
SUPPORTED_BUILDS = ['hg18', 'hg19', 'hg38']
|
242
|
+
|
243
|
+
# Handles UCSC-style range queries (hg19:chr1:100-200)
|
244
|
+
def self.from_string(string, overlap=false)
|
245
|
+
begin
|
246
|
+
build, chromosome, pos = string.split(':')
|
247
|
+
rescue ValueError
|
248
|
+
raise ValueError,
|
249
|
+
'Please use UCSC-style format: "hg19:chr2:1000-2000"'
|
250
|
+
end
|
251
|
+
|
252
|
+
if pos.member?('-')
|
253
|
+
start, last = pos.replace(',', '').split('-')
|
254
|
+
else
|
255
|
+
start = last = pos.replace(',', '')
|
256
|
+
end
|
257
|
+
|
258
|
+
return self.new(build, chromosome, start, last, overlap=overlap)
|
259
|
+
end
|
260
|
+
|
261
|
+
# Shortcut to do range queries on supported datasets.
|
262
|
+
def initialize(build, chromosome, start, last, overlap=false)
|
263
|
+
if !SUPPORTED_BUILDS.member?(build.downcase)
|
264
|
+
msg = "Build #{build} not supported for range filters. " +
|
265
|
+
"Supported builds are: #{SUPPORTED_BUILDS.join(', ')}"
|
266
|
+
raise Exception, msg
|
267
|
+
end
|
268
|
+
|
269
|
+
f = SolveBio::Filter.new({"#{build}_start__range" => [start, last]})
|
270
|
+
|
271
|
+
if overlap
|
272
|
+
f |= SolveBio::Filter.
|
273
|
+
new({"#{build}_end__range" => [start, last]})
|
274
|
+
else
|
275
|
+
f &= SolveBio::Filter.
|
276
|
+
new({"#{build}_end__range" => [start, last]})
|
277
|
+
end
|
278
|
+
|
279
|
+
f &= SolveBio::Filter.
|
280
|
+
new({"#{build}_chromosome" => chromosome.sub('chr', '')})
|
281
|
+
@filters = f.filters
|
282
|
+
end
|
283
|
+
|
284
|
+
def inspect
|
285
|
+
return "<RangeFilter #{@filters}>"
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
|
290
|
+
# Demo/test code
|
291
|
+
if __FILE__ == $0
|
292
|
+
filters =
|
293
|
+
SolveBio::Filter.new(:omim_id => 144650) |
|
294
|
+
SolveBio::Filter.new(:omim_id => 144600) |
|
295
|
+
SolveBio::Filter.new(:omim_id => 145300)
|
296
|
+
puts filters.inspect
|
297
|
+
puts SolveBio::Filter.process_filters([[:omim_id, nil]]).inspect
|
298
|
+
f = SolveBio::Filter.new
|
299
|
+
puts "%s, empty?: %s" % [f.inspect, f.empty?]
|
300
|
+
f_not = ~f
|
301
|
+
puts "%s, empty?: %s" % [f_not.inspect, f_not.empty?]
|
302
|
+
f2 = SolveBio::Filter.new({:style => 'Mexican', :price => 'Free'})
|
303
|
+
puts "%s, empty? %s" % [f2.inspect, f2.empty?]
|
304
|
+
f2_not = ~f2
|
305
|
+
puts "%s, empty? %s" % [f2_not.inspect, f2_not.empty?]
|
306
|
+
# FIXME: using a hash means we can't repeat chr1. Is this intended?
|
307
|
+
f2_or = SolveBio::Filter.new({:chr1 => '3', :chr2 => '4'}, :or)
|
308
|
+
puts "%s, empty %s" % [f2_or.inspect, f2_or.empty?]
|
309
|
+
f2_or = SolveBio::Filter.new({:chr1 => '3'}) | SolveBio::Filter.new({:chr2 => '4'})
|
310
|
+
puts "%s, empty %s" % [f2_or.inspect, f2_or.empty?]
|
311
|
+
puts((f2_or & f2).inspect)
|
312
|
+
end
|