solvebio 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. data/.gitignore +7 -0
  2. data/.travis.yml +13 -0
  3. data/Gemfile +4 -0
  4. data/Gemspec +3 -0
  5. data/LICENSE +21 -0
  6. data/Makefile +17 -0
  7. data/README.md +64 -0
  8. data/Rakefile +59 -0
  9. data/bin/solvebio.rb +36 -0
  10. data/demo/README.md +14 -0
  11. data/demo/dataset/facets.rb +13 -0
  12. data/demo/dataset/field.rb +13 -0
  13. data/demo/depository/README.md +24 -0
  14. data/demo/depository/all.rb +13 -0
  15. data/demo/depository/retrieve.rb +13 -0
  16. data/demo/depository/versions-all.rb +13 -0
  17. data/demo/query/query-filter.rb +30 -0
  18. data/demo/query/query.rb +13 -0
  19. data/demo/query/range-filter.rb +18 -0
  20. data/demo/test-api.rb +98 -0
  21. data/lib/apiresource.rb +130 -0
  22. data/lib/cli/auth.rb +122 -0
  23. data/lib/cli/help.rb +13 -0
  24. data/lib/cli/irb.rb +58 -0
  25. data/lib/cli/irbrc.rb +53 -0
  26. data/lib/cli/options.rb +75 -0
  27. data/lib/client.rb +152 -0
  28. data/lib/credentials.rb +67 -0
  29. data/lib/errors.rb +81 -0
  30. data/lib/filter.rb +312 -0
  31. data/lib/help.rb +46 -0
  32. data/lib/locale.rb +47 -0
  33. data/lib/main.rb +37 -0
  34. data/lib/query.rb +415 -0
  35. data/lib/resource.rb +414 -0
  36. data/lib/solvebio.rb +14 -0
  37. data/lib/solveobject.rb +101 -0
  38. data/lib/tabulate.rb +706 -0
  39. data/solvebio.gemspec +75 -0
  40. data/test/data/netrc-save +6 -0
  41. data/test/helper.rb +3 -0
  42. data/test/test-auth.rb +54 -0
  43. data/test/test-client.rb +27 -0
  44. data/test/test-error.rb +36 -0
  45. data/test/test-filter.rb +70 -0
  46. data/test/test-netrc.rb +42 -0
  47. data/test/test-query-batch.rb +60 -0
  48. data/test/test-query-init.rb +29 -0
  49. data/test/test-query-paging.rb +123 -0
  50. data/test/test-query.rb +88 -0
  51. data/test/test-resource.rb +47 -0
  52. data/test/test-solveobject.rb +27 -0
  53. data/test/test-tabulate.rb +127 -0
  54. 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
@@ -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