solvebio 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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