suo 0.1.3 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 0688005f9b0d058a3aa50d0225cf15c52b74e036
4
- data.tar.gz: 65a1be72254409473e51e7455a8e718db505c87a
3
+ metadata.gz: 413b62af263af5a28c0fbf5691b30963f42f0883
4
+ data.tar.gz: 546c7fd3bac7b2222f6d9d2701a456c6a2aa6e36
5
5
  SHA512:
6
- metadata.gz: 8e8f222bb46a28971467792d8b75c939d8e62869860a53dffa587e85cd2d9075211e229c670d14868730b714dcd2ac8db2e2a3b4666717238450a8f6022772f5
7
- data.tar.gz: 0671a1a94f678a9da525c50b756fee60b3a8e207de45693bfe79deddf9d1cf333d0be0130e166c0c1ef4a088f9e6d95810bdd955fd6d5a0c7e83df20fe5b98ca
6
+ metadata.gz: eca791c152ebf80a02307f7af951eb352b8891e1bf8ec75e674ca9db772c317df2bcdb63bc880f1c844a52f0246d0448aab6937bae9d914bf0c85c886714f61d
7
+ data.tar.gz: 721d0b259f9c87338227ec70e5d2a20b72672ab71d0402984c1e1afceb2dc71eaa3e274a50e1760ef4764220c0184a4994c0c37c6b4760579a346c91b55de6ab
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.2.0
2
+
3
+ - Refactor class methods into instance methods to simplify implementation.
4
+ - Increase thread safety with Memcached implementation.
5
+
1
6
  ## 0.1.3
2
7
 
3
8
  - Properly throw Suo::LockClientError when the connection itself fails (Memcache server not reachable, etc.)
data/README.md CHANGED
@@ -31,12 +31,22 @@ suo.lock("some_key") do
31
31
  @puppies.pet!
32
32
  end
33
33
 
34
- 2.times do
35
- Thread.new do
36
- # second argument is the number of resources - so this will run twice
37
- suo.lock("other_key", 2, timeout: 0.5) { puts "Will run twice!" }
38
- end
39
- end
34
+ Thread.new { suo.lock("other_key", 2) { puts "One"; sleep 2 } }
35
+ Thread.new { suo.lock("other_key", 2) { puts "Two"; sleep 2 } }
36
+ Thread.new { suo.lock("other_key", 2) { puts "Three" } }
37
+
38
+ # will print "One" "Two", but not "Three", as there are only 2 resources
39
+
40
+ # custom acquisition timeouts (time to acquire)
41
+ suo = Suo::Client::Memcached.new(client: some_dalli_client, acquisition_timeout: 1) # in seconds
42
+
43
+ # manually locking/unlocking
44
+ suo.lock("a_key")
45
+ foo.baz!
46
+ suo.unlock("a_key")
47
+
48
+ # custom stale lock cleanup (cleaning of dead clients)
49
+ suo = Suo::Client::Redis.new(client: some_redis_client, stale_lock_expiration: 60*5)
40
50
  ```
41
51
 
42
52
  ## TODO
@@ -1,206 +1,188 @@
1
1
  module Suo
2
2
  module Client
3
3
  class Base
4
-
5
4
  DEFAULT_OPTIONS = {
6
- retry_timeout: 0.1,
7
- retry_delay: 0.01,
5
+ acquisition_timeout: 0.1,
6
+ acquisition_delay: 0.01,
8
7
  stale_lock_expiration: 3600
9
8
  }.freeze
10
9
 
10
+ attr_accessor :client
11
+
12
+ include MonitorMixin
13
+
11
14
  def initialize(options = {})
12
- @options = self.class.merge_defaults(options)
15
+ fail "Client required" unless options[:client]
16
+ @options = DEFAULT_OPTIONS.merge(options)
17
+ @retry_count = (@options[:acquisition_timeout] / @options[:acquisition_delay].to_f).ceil
18
+ @client = @options[:client]
19
+ super()
13
20
  end
14
21
 
15
- def lock(key, resources = 1, options = {})
16
- options = self.class.merge_defaults(@options.merge(options))
17
- token = self.class.lock(key, resources, options)
22
+ def lock(key, resources = 1)
23
+ token = acquire_lock(key, resources)
18
24
 
19
- if token
25
+ if block_given? && token
20
26
  begin
21
- yield if block_given?
27
+ yield
22
28
  ensure
23
- self.class.unlock(key, token, options)
29
+ unlock(key, token)
24
30
  end
25
-
26
- true
27
31
  else
28
- false
32
+ token
29
33
  end
30
34
  end
31
35
 
32
36
  def locked?(key, resources = 1)
33
- self.class.locked?(key, resources, @options)
37
+ locks(key).size >= resources
34
38
  end
35
39
 
36
- class << self
37
- def lock(key, resources = 1, options = {})
38
- options = merge_defaults(options)
39
- acquisition_token = nil
40
- token = SecureRandom.base64(16)
41
-
42
- retry_with_timeout(key, options) do
43
- val, cas = get(key, options)
44
-
45
- if val.nil?
46
- set_initial(key, options)
47
- next
48
- end
40
+ def locks(key)
41
+ val, _ = get(key)
42
+ locks = deserialize_locks(val)
49
43
 
50
- locks = deserialize_and_clear_locks(val, options)
51
-
52
- if locks.size < resources
53
- add_lock(locks, token)
44
+ locks
45
+ end
54
46
 
55
- newval = serialize_locks(locks)
47
+ def refresh(key, acquisition_token)
48
+ retry_with_timeout(key) do
49
+ val, cas = get(key)
56
50
 
57
- if set(key, newval, cas, options)
58
- acquisition_token = token
59
- break
60
- end
61
- end
51
+ if val.nil?
52
+ set_initial(key)
53
+ next
62
54
  end
63
55
 
64
- acquisition_token
65
- end
66
-
67
- def locked?(key, resources = 1, options = {})
68
- locks(key, options).size >= resources
69
- end
56
+ locks = deserialize_and_clear_locks(val)
70
57
 
71
- def locks(key, options)
72
- options = merge_defaults(options)
73
- val, _ = get(key, options)
74
- locks = deserialize_locks(val)
58
+ refresh_lock(locks, acquisition_token)
75
59
 
76
- locks
60
+ break if set(key, serialize_locks(locks), cas)
77
61
  end
62
+ end
78
63
 
79
- def refresh(key, acquisition_token, options = {})
80
- options = merge_defaults(options)
64
+ def unlock(key, acquisition_token)
65
+ return unless acquisition_token
81
66
 
82
- retry_with_timeout(key, options) do
83
- val, cas = get(key, options)
67
+ retry_with_timeout(key) do
68
+ val, cas = get(key)
84
69
 
85
- if val.nil?
86
- set_initial(key, options)
87
- next
88
- end
70
+ break if val.nil?
89
71
 
90
- locks = deserialize_and_clear_locks(val, options)
72
+ locks = deserialize_and_clear_locks(val)
91
73
 
92
- refresh_lock(locks, acquisition_token)
74
+ acquisition_lock = remove_lock(locks, acquisition_token)
93
75
 
94
- break if set(key, serialize_locks(locks), cas, options)
95
- end
76
+ break unless acquisition_lock
77
+ break if set(key, serialize_locks(locks), cas)
96
78
  end
79
+ rescue LockClientError => _ # rubocop:disable Lint/HandleExceptions
80
+ # ignore - assume success due to optimistic locking
81
+ end
97
82
 
98
- def unlock(key, acquisition_token, options = {})
99
- options = merge_defaults(options)
100
-
101
- return unless acquisition_token
102
-
103
- retry_with_timeout(key, options) do
104
- val, cas = get(key, options)
83
+ def clear(key) # rubocop:disable Lint/UnusedMethodArgument
84
+ fail NotImplementedError
85
+ end
105
86
 
106
- break if val.nil?
87
+ private
107
88
 
108
- locks = deserialize_and_clear_locks(val, options)
89
+ def acquire_lock(key, resources = 1)
90
+ acquisition_token = nil
91
+ token = SecureRandom.base64(16)
109
92
 
110
- acquisition_lock = remove_lock(locks, acquisition_token)
93
+ retry_with_timeout(key) do
94
+ val, cas = get(key)
111
95
 
112
- break unless acquisition_lock
113
- break if set(key, serialize_locks(locks), cas, options)
96
+ if val.nil?
97
+ set_initial(key)
98
+ next
114
99
  end
115
- rescue LockClientError => _ # rubocop:disable Lint/HandleExceptions
116
- # ignore - assume success due to optimistic locking
117
- end
118
-
119
- def clear(key, options = {}) # rubocop:disable Lint/UnusedMethodArgument
120
- fail NotImplementedError
121
- end
122
100
 
123
- def merge_defaults(options = {})
124
- options = self::DEFAULT_OPTIONS.merge(options)
101
+ locks = deserialize_and_clear_locks(val)
125
102
 
126
- fail "Client required" unless options[:client]
103
+ if locks.size < resources
104
+ add_lock(locks, token)
127
105
 
128
- options
129
- end
130
-
131
- private
106
+ newval = serialize_locks(locks)
132
107
 
133
- def get(key, options) # rubocop:disable Lint/UnusedMethodArgument
134
- fail NotImplementedError
108
+ if set(key, newval, cas)
109
+ acquisition_token = token
110
+ break
111
+ end
112
+ end
135
113
  end
136
114
 
137
- def set(key, newval, oldval, options) # rubocop:disable Lint/UnusedMethodArgument
138
- fail NotImplementedError
139
- end
115
+ acquisition_token
116
+ end
140
117
 
141
- def set_initial(key, options) # rubocop:disable Lint/UnusedMethodArgument
142
- fail NotImplementedError
143
- end
118
+ def get(key) # rubocop:disable Lint/UnusedMethodArgument
119
+ fail NotImplementedError
120
+ end
144
121
 
145
- def synchronize(key, options)
146
- yield(key, options)
147
- end
122
+ def set(key, newval, oldval) # rubocop:disable Lint/UnusedMethodArgument
123
+ fail NotImplementedError
124
+ end
148
125
 
149
- def retry_with_timeout(key, options)
150
- count = (options[:retry_timeout] / options[:retry_delay].to_f).ceil
126
+ def set_initial(key) # rubocop:disable Lint/UnusedMethodArgument
127
+ fail NotImplementedError
128
+ end
151
129
 
152
- start = Time.now.to_f
130
+ def synchronize(key) # rubocop:disable Lint/UnusedMethodArgument
131
+ mon_synchronize { yield }
132
+ end
153
133
 
154
- count.times do
155
- now = Time.now.to_f
156
- break if now - start > options[:retry_timeout]
134
+ def retry_with_timeout(key)
135
+ start = Time.now.to_f
157
136
 
158
- synchronize(key, options) do
159
- yield
160
- end
137
+ @retry_count.times do
138
+ now = Time.now.to_f
139
+ break if now - start > @options[:acquisition_timeout]
161
140
 
162
- sleep(rand(options[:retry_delay] * 1000).to_f / 1000)
141
+ synchronize(key) do
142
+ yield
163
143
  end
164
- rescue => _
165
- raise LockClientError
166
- end
167
144
 
168
- def serialize_locks(locks)
169
- MessagePack.pack(locks.map { |time, token| [time.to_f, token] })
145
+ sleep(rand(@options[:acquisition_delay] * 1000).to_f / 1000)
170
146
  end
147
+ rescue => _
148
+ raise LockClientError
149
+ end
171
150
 
172
- def deserialize_and_clear_locks(val, options)
173
- clear_expired_locks(deserialize_locks(val), options)
174
- end
151
+ def serialize_locks(locks)
152
+ MessagePack.pack(locks.map { |time, token| [time.to_f, token] })
153
+ end
175
154
 
176
- def deserialize_locks(val)
177
- unpacked = (val.nil? || val == "") ? [] : MessagePack.unpack(val)
155
+ def deserialize_and_clear_locks(val)
156
+ clear_expired_locks(deserialize_locks(val))
157
+ end
178
158
 
179
- unpacked.map do |time, token|
180
- [Time.at(time), token]
181
- end
182
- rescue EOFError => _
183
- []
184
- end
159
+ def deserialize_locks(val)
160
+ unpacked = (val.nil? || val == "") ? [] : MessagePack.unpack(val)
185
161
 
186
- def clear_expired_locks(locks, options)
187
- expired = Time.now - options[:stale_lock_expiration]
188
- locks.reject { |time, _| time < expired }
162
+ unpacked.map do |time, token|
163
+ [Time.at(time), token]
189
164
  end
165
+ rescue EOFError => _
166
+ []
167
+ end
190
168
 
191
- def add_lock(locks, token)
192
- locks << [Time.now.to_f, token]
193
- end
169
+ def clear_expired_locks(locks)
170
+ expired = Time.now - @options[:stale_lock_expiration]
171
+ locks.reject { |time, _| time < expired }
172
+ end
194
173
 
195
- def remove_lock(locks, acquisition_token)
196
- lock = locks.find { |_, token| token == acquisition_token }
197
- locks.delete(lock)
198
- end
174
+ def add_lock(locks, token)
175
+ locks << [Time.now.to_f, token]
176
+ end
199
177
 
200
- def refresh_lock(locks, acquisition_token)
201
- remove_lock(locks, acquisition_token)
202
- add_lock(locks, token)
203
- end
178
+ def remove_lock(locks, acquisition_token)
179
+ lock = locks.find { |_, token| token == acquisition_token }
180
+ locks.delete(lock)
181
+ end
182
+
183
+ def refresh_lock(locks, acquisition_token)
184
+ remove_lock(locks, acquisition_token)
185
+ add_lock(locks, token)
204
186
  end
205
187
  end
206
188
  end
@@ -6,25 +6,22 @@ module Suo
6
6
  super
7
7
  end
8
8
 
9
- class << self
10
- def clear(key, options = {})
11
- options = merge_defaults(options)
12
- options[:client].delete(key)
13
- end
9
+ def clear(key)
10
+ @client.delete(key)
11
+ end
14
12
 
15
- private
13
+ private
16
14
 
17
- def get(key, options)
18
- options[:client].get_cas(key)
19
- end
15
+ def get(key)
16
+ @client.get_cas(key)
17
+ end
20
18
 
21
- def set(key, newval, cas, options)
22
- options[:client].set_cas(key, newval, cas)
23
- end
19
+ def set(key, newval, cas)
20
+ @client.set_cas(key, newval, cas)
21
+ end
24
22
 
25
- def set_initial(key, options)
26
- options[:client].set(key, "")
27
- end
23
+ def set_initial(key)
24
+ @client.set(key, "")
28
25
  end
29
26
  end
30
27
  end
@@ -6,37 +6,34 @@ module Suo
6
6
  super
7
7
  end
8
8
 
9
- class << self
10
- def clear(key, options = {})
11
- options = merge_defaults(options)
12
- options[:client].del(key)
13
- end
9
+ def clear(key)
10
+ @client.del(key)
11
+ end
14
12
 
15
- private
13
+ private
16
14
 
17
- def get(key, options)
18
- [options[:client].get(key), nil]
19
- end
20
-
21
- def set(key, newval, _, options)
22
- ret = options[:client].multi do |multi|
23
- multi.set(key, newval)
24
- end
15
+ def get(key)
16
+ [@client.get(key), nil]
17
+ end
25
18
 
26
- ret[0] == "OK"
19
+ def set(key, newval, _)
20
+ ret = @client.multi do |multi|
21
+ multi.set(key, newval)
27
22
  end
28
23
 
29
- def synchronize(key, options)
30
- options[:client].watch(key) do
31
- yield
32
- end
33
- ensure
34
- options[:client].unwatch
35
- end
24
+ ret[0] == "OK"
25
+ end
36
26
 
37
- def set_initial(key, options)
38
- options[:client].set(key, "")
27
+ def synchronize(key)
28
+ @client.watch(key) do
29
+ yield
39
30
  end
31
+ ensure
32
+ @client.unwatch
33
+ end
34
+
35
+ def set_initial(key)
36
+ @client.set(key, "")
40
37
  end
41
38
  end
42
39
  end
data/lib/suo/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Suo
2
- VERSION = "0.1.3"
2
+ VERSION = "0.2.0"
3
3
  end
data/test/client_test.rb CHANGED
@@ -3,62 +3,55 @@ require "test_helper"
3
3
  TEST_KEY = "suo_test_key".freeze
4
4
 
5
5
  module ClientTests
6
- def test_requires_client
7
- exception = assert_raises(RuntimeError) do
8
- @klass.lock(TEST_KEY, 1)
9
- end
10
-
11
- assert_equal "Client required", exception.message
12
- end
13
-
14
6
  def test_throws_failed_error_on_bad_client
15
7
  assert_raises(Suo::LockClientError) do
16
- @klass.lock(TEST_KEY, 1, client: {})
8
+ client = @client.class.new(client: {})
9
+ client.lock(TEST_KEY, 1)
17
10
  end
18
11
  end
19
12
 
20
13
  def test_class_single_resource_locking
21
- lock1 = @klass.lock(TEST_KEY, 1, client: @klass_client)
14
+ lock1 = @client.lock(TEST_KEY, 1)
22
15
  refute_nil lock1
23
16
 
24
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
17
+ locked = @client.locked?(TEST_KEY, 1)
25
18
  assert_equal true, locked
26
19
 
27
- lock2 = @klass.lock(TEST_KEY, 1, client: @klass_client)
20
+ lock2 = @client.lock(TEST_KEY, 1)
28
21
  assert_nil lock2
29
22
 
30
- @klass.unlock(TEST_KEY, lock1, client: @klass_client)
23
+ @client.unlock(TEST_KEY, lock1)
31
24
 
32
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
25
+ locked = @client.locked?(TEST_KEY, 1)
33
26
 
34
27
  assert_equal false, locked
35
28
  end
36
29
 
37
30
  def test_class_multiple_resource_locking
38
- lock1 = @klass.lock(TEST_KEY, 2, client: @klass_client)
31
+ lock1 = @client.lock(TEST_KEY, 2)
39
32
  refute_nil lock1
40
33
 
41
- locked = @klass.locked?(TEST_KEY, 2, client: @klass_client)
34
+ locked = @client.locked?(TEST_KEY, 2)
42
35
  assert_equal false, locked
43
36
 
44
- lock2 = @klass.lock(TEST_KEY, 2, client: @klass_client)
37
+ lock2 = @client.lock(TEST_KEY, 2)
45
38
  refute_nil lock2
46
39
 
47
- locked = @klass.locked?(TEST_KEY, 2, client: @klass_client)
40
+ locked = @client.locked?(TEST_KEY, 2)
48
41
  assert_equal true, locked
49
42
 
50
- @klass.unlock(TEST_KEY, lock1, client: @klass_client)
43
+ @client.unlock(TEST_KEY, lock1)
51
44
 
52
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
45
+ locked = @client.locked?(TEST_KEY, 1)
53
46
  assert_equal true, locked
54
47
 
55
- @klass.unlock(TEST_KEY, lock2, client: @klass_client)
48
+ @client.unlock(TEST_KEY, lock2)
56
49
 
57
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
50
+ locked = @client.locked?(TEST_KEY, 1)
58
51
  assert_equal false, locked
59
52
  end
60
53
 
61
- def test_instance_single_resource_locking
54
+ def test_block_single_resource_locking
62
55
  locked = false
63
56
 
64
57
  @client.lock(TEST_KEY, 1) { locked = true }
@@ -66,22 +59,45 @@ module ClientTests
66
59
  assert_equal true, locked
67
60
  end
68
61
 
69
- def test_instance_unlocks_on_exception
62
+ def test_block_unlocks_on_exception
70
63
  assert_raises(RuntimeError) do
71
64
  @client.lock(TEST_KEY, 1) { fail "Test" }
72
65
  end
73
66
 
74
- locked = @klass.locked?(TEST_KEY, 1, client: @klass_client)
67
+ locked = @client.locked?(TEST_KEY, 1)
75
68
  assert_equal false, locked
76
69
  end
77
70
 
71
+ def test_readme_example
72
+ output = Queue.new
73
+ threads = []
74
+
75
+ threads << Thread.new { @client.lock(TEST_KEY, 2) { output << "One"; sleep 2 } }
76
+ threads << Thread.new { @client.lock(TEST_KEY, 2) { output << "Two"; sleep 2 } }
77
+ threads << Thread.new { @client.lock(TEST_KEY, 2) { output << "Three" } }
78
+
79
+ threads.map(&:join)
80
+
81
+ ret = []
82
+
83
+ ret << output.pop
84
+ ret << output.pop
85
+
86
+ ret.sort!
87
+
88
+ assert_equal 0, output.size
89
+ assert_equal ["One", "Two"], ret
90
+ end
91
+
78
92
  def test_instance_multiple_resource_locking
79
93
  success_counter = Queue.new
80
94
  failure_counter = Queue.new
81
95
 
82
- 50.times.map do |i|
96
+ client = @client.class.new(acquisition_timeout: 0.9, client: @client.client)
97
+
98
+ 100.times.map do |i|
83
99
  Thread.new do
84
- success = @client.lock(TEST_KEY, 25, retry_timeout: 0.9) do
100
+ success = @client.lock(TEST_KEY, 50) do
85
101
  sleep(3)
86
102
  success_counter << i
87
103
  end
@@ -90,17 +106,19 @@ module ClientTests
90
106
  end
91
107
  end.map(&:join)
92
108
 
93
- assert_equal 25, success_counter.size
94
- assert_equal 25, failure_counter.size
109
+ assert_equal 50, success_counter.size
110
+ assert_equal 50, failure_counter.size
95
111
  end
96
112
 
97
113
  def test_instance_multiple_resource_locking_longer_timeout
98
114
  success_counter = Queue.new
99
115
  failure_counter = Queue.new
100
116
 
101
- 50.times.map do |i|
117
+ client = @client.class.new(acquisition_timeout: 3, client: @client.client)
118
+
119
+ 100.times.map do |i|
102
120
  Thread.new do
103
- success = @client.lock(TEST_KEY, 25, retry_timeout: 2) do
121
+ success = client.lock(TEST_KEY, 50) do
104
122
  sleep(0.5)
105
123
  success_counter << i
106
124
  end
@@ -109,19 +127,19 @@ module ClientTests
109
127
  end
110
128
  end.map(&:join)
111
129
 
112
- assert_equal 50, success_counter.size
130
+ assert_equal 100, success_counter.size
113
131
  assert_equal 0, failure_counter.size
114
132
  end
115
133
  end
116
134
 
117
135
  class TestBaseClient < Minitest::Test
118
136
  def setup
119
- @klass = Suo::Client::Base
137
+ @client = Suo::Client::Base.new(client: {})
120
138
  end
121
139
 
122
140
  def test_not_implemented
123
141
  assert_raises(NotImplementedError) do
124
- @klass.send(:get, TEST_KEY, {})
142
+ @client.send(:get, TEST_KEY)
125
143
  end
126
144
  end
127
145
  end
@@ -130,13 +148,12 @@ class TestMemcachedClient < Minitest::Test
130
148
  include ClientTests
131
149
 
132
150
  def setup
133
- @klass = Suo::Client::Memcached
134
- @client = @klass.new
135
- @klass_client = Dalli::Client.new("127.0.0.1:11211")
151
+ @dalli = Dalli::Client.new("127.0.0.1:11211")
152
+ @client = Suo::Client::Memcached.new
136
153
  end
137
154
 
138
155
  def teardown
139
- @klass_client.delete(TEST_KEY)
156
+ @dalli.delete(TEST_KEY)
140
157
  end
141
158
  end
142
159
 
@@ -144,13 +161,12 @@ class TestRedisClient < Minitest::Test
144
161
  include ClientTests
145
162
 
146
163
  def setup
147
- @klass = Suo::Client::Redis
148
- @client = @klass.new
149
- @klass_client = Redis.new
164
+ @redis = Redis.new
165
+ @client = Suo::Client::Redis.new
150
166
  end
151
167
 
152
168
  def teardown
153
- @klass_client.del(TEST_KEY)
169
+ @redis.del(TEST_KEY)
154
170
  end
155
171
  end
156
172
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: suo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Elser