timeout_cache 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +11 -1
- data/lib/timeout_cache.rb +26 -8
- data/lib/timeout_cache.rb~ +26 -8
- data/rakefile +10 -1
- data/test/timeout_cache_spec.rb +66 -4
- data/test/timeout_cache_spec.rb~ +68 -4
- data/timeout_cache.gemspec +1 -0
- metadata +23 -1
data/README.md
CHANGED
@@ -2,6 +2,10 @@
|
|
2
2
|
|
3
3
|
A `TimeoutCache` is a simple cache where objects are stored with a fixed expiration time. It is useful when you want to keep an object around for a sufficiently long period of time, but want them to disappear after, say, 5 seconds, or on the first day of next month.
|
4
4
|
|
5
|
+
```
|
6
|
+
gem install timeout_cache
|
7
|
+
```
|
8
|
+
|
5
9
|
# How do I use this thing?
|
6
10
|
|
7
11
|
A `TimeoutCache` is easy to use.
|
@@ -31,6 +35,8 @@ You can also use an instance of `Time`:
|
|
31
35
|
cache.set(:x, :y, :time => Time.now + 50)
|
32
36
|
```
|
33
37
|
|
38
|
+
You can use any object as the value for `:time` provided it is an `Integer`, a `Time`, has a `to_int` method, or has a `to_time` method.
|
39
|
+
|
34
40
|
If you want to change the default expiration time, do so when you make a new instance of `TimeoutCache` by passing the length of time in seconds.
|
35
41
|
|
36
42
|
```ruby
|
@@ -41,7 +47,11 @@ Unless an object is added to the cache using the `:time` option, the default is
|
|
41
47
|
|
42
48
|
# Entry deletion and pruning
|
43
49
|
|
44
|
-
Expired entries are deleted lazily. If an entry is added with an expire time of 15 seconds and nothing touches the cache, the entry will still be in the cache after 20 seconds. Expired entries are deleted
|
50
|
+
Expired entries are deleted lazily to try and avoid to cost of excessively pruning. If an entry is added with an expire time of 15 seconds and nothing touches the cache, the entry will still be in the cache after 20 seconds. Expired entries are deleted when certain methods are called:
|
51
|
+
|
52
|
+
* `#[]` (or, equivalently, `#get`) provided that the entry being retrieved has expired
|
53
|
+
* `#size`
|
54
|
+
* `#empty?`
|
45
55
|
|
46
56
|
If you want to manually prune the cache, you may do so by calling `#prune`.
|
47
57
|
|
data/lib/timeout_cache.rb
CHANGED
@@ -13,7 +13,7 @@
|
|
13
13
|
# cache[:foo] #=> nil
|
14
14
|
#
|
15
15
|
class TimeoutCache
|
16
|
-
VERSION = "0.0.
|
16
|
+
VERSION = "0.0.2"
|
17
17
|
|
18
18
|
# Wraps an object by attaching a time to it.
|
19
19
|
class TimedObject # :nodoc:
|
@@ -38,12 +38,15 @@ class TimeoutCache
|
|
38
38
|
|
39
39
|
# Creates a new TimeoutCache.
|
40
40
|
#
|
41
|
-
# If <tt>timeout</tt> is specified,
|
42
|
-
#
|
41
|
+
# If <tt>timeout</tt> is specified, #to_int is called on the argument,
|
42
|
+
# and the return value is used as the default survival time for
|
43
|
+
# a cache entry in seconds.
|
43
44
|
#
|
44
45
|
# If no default value is used, then DEFAULT_TIMEOUT is the default
|
45
46
|
# time-to-expire in seconds.
|
46
47
|
def initialize(timeout = DEFAULT_TIMEOUT)
|
48
|
+
timeout = timeout.to_int if timeout.respond_to?(:to_int)
|
49
|
+
|
47
50
|
raise ArgumentError.new("Timeout must be > 0") unless timeout > 0
|
48
51
|
|
49
52
|
@timeout = timeout
|
@@ -52,8 +55,9 @@ class TimeoutCache
|
|
52
55
|
@store = {}
|
53
56
|
end
|
54
57
|
|
55
|
-
#
|
58
|
+
# Calls #prune and then returns the number of items in the cache.
|
56
59
|
def size
|
60
|
+
prune
|
57
61
|
@store.size
|
58
62
|
end
|
59
63
|
alias_method :length, :size
|
@@ -62,8 +66,7 @@ class TimeoutCache
|
|
62
66
|
# then this returns nil. If the value has an expire time earlier than or equal
|
63
67
|
# to the current time, this returns nil.
|
64
68
|
#
|
65
|
-
#
|
66
|
-
# an element that has expired.
|
69
|
+
# This method calls #prune whenever it finds an element that has expired.
|
67
70
|
def [](key)
|
68
71
|
val = @store[key]
|
69
72
|
|
@@ -103,13 +106,27 @@ class TimeoutCache
|
|
103
106
|
def set(key, value, options = {})
|
104
107
|
time = options[:time] || timeout
|
105
108
|
|
109
|
+
# we can technically do away with checking against Integer explicitly since
|
110
|
+
# the to_int call will take care of it for us through Integer#to_int returning
|
111
|
+
# self, but it's here for the sake of clarity, mostly.
|
112
|
+
#
|
113
|
+
# on the other hand, the #to_time call is necessary, since, for some reason,
|
114
|
+
# Time#to_time only exists if you require "time".
|
106
115
|
time = case time
|
107
116
|
when Integer
|
108
117
|
Time.now + time
|
109
118
|
when Time
|
110
119
|
time
|
120
|
+
else
|
121
|
+
if time.respond_to?(:to_int)
|
122
|
+
Time.now + time.to_int
|
123
|
+
elsif time.respond_to?(:to_time)
|
124
|
+
time.to_time
|
125
|
+
end
|
111
126
|
end
|
112
127
|
|
128
|
+
raise(ArgumentError, "time argument #{time.inspect} could not be converted to a time") if time.nil?
|
129
|
+
|
113
130
|
return nil if time <= Time.now
|
114
131
|
|
115
132
|
v = TimedObject.new(value, time)
|
@@ -118,8 +135,9 @@ class TimeoutCache
|
|
118
135
|
value
|
119
136
|
end
|
120
137
|
|
121
|
-
#
|
138
|
+
# Calls #prune and then returns true if the cache is empty, otherwise false.
|
122
139
|
def empty?
|
140
|
+
prune
|
123
141
|
@store.empty?
|
124
142
|
end
|
125
143
|
|
@@ -136,7 +154,7 @@ class TimeoutCache
|
|
136
154
|
# If nothing was removed, returns nil, otherwise returns
|
137
155
|
# the number of elements removed.
|
138
156
|
def prune
|
139
|
-
return nil if empty?
|
157
|
+
return nil if @store.empty?
|
140
158
|
|
141
159
|
count = 0
|
142
160
|
|
data/lib/timeout_cache.rb~
CHANGED
@@ -16,7 +16,7 @@ class TimeoutCache
|
|
16
16
|
VERSION = "0.0.1"
|
17
17
|
|
18
18
|
# Wraps an object by attaching a time to it.
|
19
|
-
class TimedObject
|
19
|
+
class TimedObject # :nodoc:
|
20
20
|
attr_reader :value, :expires_at
|
21
21
|
|
22
22
|
def initialize(value, time)
|
@@ -38,12 +38,15 @@ class TimeoutCache
|
|
38
38
|
|
39
39
|
# Creates a new TimeoutCache.
|
40
40
|
#
|
41
|
-
# If <tt>timeout</tt> is specified,
|
42
|
-
#
|
41
|
+
# If <tt>timeout</tt> is specified, #to_int is called on the argument,
|
42
|
+
# and the return value is used as the default survival time for
|
43
|
+
# a cache entry in seconds.
|
43
44
|
#
|
44
45
|
# If no default value is used, then DEFAULT_TIMEOUT is the default
|
45
46
|
# time-to-expire in seconds.
|
46
47
|
def initialize(timeout = DEFAULT_TIMEOUT)
|
48
|
+
timeout = timeout.to_int if timeout.respond_to?(:to_int)
|
49
|
+
|
47
50
|
raise ArgumentError.new("Timeout must be > 0") unless timeout > 0
|
48
51
|
|
49
52
|
@timeout = timeout
|
@@ -52,8 +55,9 @@ class TimeoutCache
|
|
52
55
|
@store = {}
|
53
56
|
end
|
54
57
|
|
55
|
-
#
|
58
|
+
# Calls #prune and then returns the number of items in the cache.
|
56
59
|
def size
|
60
|
+
prune
|
57
61
|
@store.size
|
58
62
|
end
|
59
63
|
alias_method :length, :size
|
@@ -62,8 +66,7 @@ class TimeoutCache
|
|
62
66
|
# then this returns nil. If the value has an expire time earlier than or equal
|
63
67
|
# to the current time, this returns nil.
|
64
68
|
#
|
65
|
-
#
|
66
|
-
# an element that has expired.
|
69
|
+
# This method calls #prune whenever it finds an element that has expired.
|
67
70
|
def [](key)
|
68
71
|
val = @store[key]
|
69
72
|
|
@@ -103,13 +106,27 @@ class TimeoutCache
|
|
103
106
|
def set(key, value, options = {})
|
104
107
|
time = options[:time] || timeout
|
105
108
|
|
109
|
+
# we can technically do away with checking against Integer explicitly since
|
110
|
+
# the to_int call will take care of it for us through Integer#to_int returning
|
111
|
+
# self, but it's here for the sake of clarity, mostly.
|
112
|
+
#
|
113
|
+
# on the other hand, the #to_time call is necessary, since, for some reason,
|
114
|
+
# Time#to_time only exists if you require "time".
|
106
115
|
time = case time
|
107
116
|
when Integer
|
108
117
|
Time.now + time
|
109
118
|
when Time
|
110
119
|
time
|
120
|
+
else
|
121
|
+
if time.respond_to?(:to_int)
|
122
|
+
Time.now + time.to_int
|
123
|
+
elsif time.respond_to?(:to_time)
|
124
|
+
time.to_time
|
125
|
+
end
|
111
126
|
end
|
112
127
|
|
128
|
+
raise(ArgumentError, "time argument #{time.inspect} could not be converted to a time") if time.nil?
|
129
|
+
|
113
130
|
return nil if time <= Time.now
|
114
131
|
|
115
132
|
v = TimedObject.new(value, time)
|
@@ -118,8 +135,9 @@ class TimeoutCache
|
|
118
135
|
value
|
119
136
|
end
|
120
137
|
|
121
|
-
#
|
138
|
+
# Calls #prune and then returns true if the cache is empty, otherwise false.
|
122
139
|
def empty?
|
140
|
+
prune
|
123
141
|
@store.empty?
|
124
142
|
end
|
125
143
|
|
@@ -136,7 +154,7 @@ class TimeoutCache
|
|
136
154
|
# If nothing was removed, returns nil, otherwise returns
|
137
155
|
# the number of elements removed.
|
138
156
|
def prune
|
139
|
-
return nil if empty?
|
157
|
+
return nil if @store.empty?
|
140
158
|
|
141
159
|
count = 0
|
142
160
|
|
data/rakefile
CHANGED
@@ -6,7 +6,7 @@ $:.unshift(File.expand_path("lib", __FILE__))
|
|
6
6
|
require "timeout_cache"
|
7
7
|
|
8
8
|
RSpec::Core::RakeTask.new(:test) do |t|
|
9
|
-
t.rspec_opts = "-I test --color
|
9
|
+
t.rspec_opts = "-I test --color"
|
10
10
|
t.pattern = "test/**/*_spec.rb"
|
11
11
|
t.verbose = false
|
12
12
|
t.fail_on_error = true
|
@@ -23,4 +23,13 @@ Rake::RDocTask.new(:docs) do |rd|
|
|
23
23
|
rd.rdoc_files.include("README.md", "lib")
|
24
24
|
end
|
25
25
|
|
26
|
+
namespace :gem do
|
27
|
+
desc "Build the gem from the .gemspec"
|
28
|
+
task :build do
|
29
|
+
sh("gem build timeout_cache.gemspec")
|
30
|
+
puts
|
31
|
+
puts "You can push with gem push <built-gem>.gem"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
26
35
|
task :default => :test
|
data/test/timeout_cache_spec.rb
CHANGED
@@ -1,7 +1,18 @@
|
|
1
1
|
require "timeout_cache"
|
2
2
|
require "rspec"
|
3
|
+
require "timecop"
|
3
4
|
|
4
5
|
describe TimeoutCache do
|
6
|
+
def sleep(n)
|
7
|
+
Timecop.travel(Time.now + n)
|
8
|
+
end
|
9
|
+
|
10
|
+
after :all do
|
11
|
+
# If we don't return, the runtime for the tests makes it seem like
|
12
|
+
# we had actually slept!
|
13
|
+
Timecop.return
|
14
|
+
end
|
15
|
+
|
5
16
|
it "uses the global default timeout with no time specified" do
|
6
17
|
subject.timeout.should == TimeoutCache::DEFAULT_TIMEOUT
|
7
18
|
end
|
@@ -10,6 +21,17 @@ describe TimeoutCache do
|
|
10
21
|
TimeoutCache.new(50).timeout.should == 50
|
11
22
|
end
|
12
23
|
|
24
|
+
it "can be instantiated with anything that responds to to_int" do
|
25
|
+
x = double("")
|
26
|
+
x.should_receive(:to_int).and_return(2)
|
27
|
+
|
28
|
+
c = TimeoutCache.new(x)
|
29
|
+
c[:a] = :b
|
30
|
+
c[:a].should == :b
|
31
|
+
sleep 3
|
32
|
+
c[:a].should be_nil
|
33
|
+
end
|
34
|
+
|
13
35
|
it "cannot be instantiated with a global timeout <= 0" do
|
14
36
|
[0, -1, -5].each do |i|
|
15
37
|
expect { TimeoutCache.new(i) }.to raise_error(ArgumentError)
|
@@ -30,6 +52,32 @@ describe TimeoutCache do
|
|
30
52
|
subject.expire_time(:a).should == t
|
31
53
|
end
|
32
54
|
|
55
|
+
context "if the :time value is neither a Time nor an Integer" do
|
56
|
+
it "accepts any object which responds to #to_time" do
|
57
|
+
s = double("")
|
58
|
+
s.should_receive(:to_time).and_return(Time.now + 2)
|
59
|
+
|
60
|
+
subject.set(:a, 5, :time => s)
|
61
|
+
subject[:a].should_not be_nil
|
62
|
+
sleep 3
|
63
|
+
subject[:a].should be_nil
|
64
|
+
end
|
65
|
+
|
66
|
+
it "accepts any object which responds to #to_int" do
|
67
|
+
s = double("")
|
68
|
+
s.should_receive(:to_int).any_number_of_times.and_return(2)
|
69
|
+
|
70
|
+
subject.set(:a, 5, :time => s)
|
71
|
+
subject[:a].should_not be_nil
|
72
|
+
sleep(s.to_int + 1)
|
73
|
+
subject[:a].should be_nil
|
74
|
+
end
|
75
|
+
|
76
|
+
it "throws ArgumentError if it can't get a time out of it" do
|
77
|
+
expect { subject.set(:a, :b, :time => "not a time") }.to raise_error(ArgumentError)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
33
81
|
# here be dragons. if it takes too long to execute these
|
34
82
|
# commands, the times won't match, so this test isn't deterministic
|
35
83
|
it "sets a default timeout time with no time value specified" do
|
@@ -116,6 +164,12 @@ describe TimeoutCache do
|
|
116
164
|
subject[:a] = 1
|
117
165
|
subject.size.should_not == 0
|
118
166
|
end
|
167
|
+
|
168
|
+
it "ignores expired entries" do
|
169
|
+
subject.set(:a, :b, :time => 1)
|
170
|
+
sleep 2
|
171
|
+
subject.size.should == 0
|
172
|
+
end
|
119
173
|
end
|
120
174
|
|
121
175
|
describe "pruning" do
|
@@ -123,18 +177,26 @@ describe TimeoutCache do
|
|
123
177
|
subject.set(:a, 1, :time => 2)
|
124
178
|
subject.set(:b, 1, :time => 2)
|
125
179
|
sleep 3
|
126
|
-
subject.
|
180
|
+
subject.should_receive(:prune)
|
127
181
|
subject.get(:a)
|
128
|
-
subject.size.should == 0
|
129
182
|
end
|
130
183
|
|
131
184
|
it "does not happen when calling #get(key) when get(key) is not expired" do
|
132
185
|
subject.set(:a, 1, :time => 200)
|
133
186
|
subject.set(:b, 1, :time => 2)
|
134
187
|
sleep 3
|
135
|
-
subject.
|
188
|
+
subject.should_not_receive(:prune)
|
136
189
|
subject.get(:a)
|
137
|
-
|
190
|
+
end
|
191
|
+
|
192
|
+
it "happens on #size call" do
|
193
|
+
subject.should_receive(:prune)
|
194
|
+
subject.size
|
195
|
+
end
|
196
|
+
|
197
|
+
it "happens on an #empty? call" do
|
198
|
+
subject.should_receive(:prune)
|
199
|
+
subject.empty?
|
138
200
|
end
|
139
201
|
end
|
140
202
|
|
data/test/timeout_cache_spec.rb~
CHANGED
@@ -1,7 +1,16 @@
|
|
1
1
|
require "timeout_cache"
|
2
2
|
require "rspec"
|
3
|
+
require "timecop"
|
3
4
|
|
4
5
|
describe TimeoutCache do
|
6
|
+
def sleep(n)
|
7
|
+
Timecop.travel(Time.now + n)
|
8
|
+
end
|
9
|
+
|
10
|
+
after :all do
|
11
|
+
Timecop.return
|
12
|
+
end
|
13
|
+
|
5
14
|
it "uses the global default timeout with no time specified" do
|
6
15
|
subject.timeout.should == TimeoutCache::DEFAULT_TIMEOUT
|
7
16
|
end
|
@@ -10,6 +19,17 @@ describe TimeoutCache do
|
|
10
19
|
TimeoutCache.new(50).timeout.should == 50
|
11
20
|
end
|
12
21
|
|
22
|
+
it "can be instantiated with anything that responds to to_int" do
|
23
|
+
x = double("")
|
24
|
+
x.should_receive(:to_int).and_return(2)
|
25
|
+
|
26
|
+
c = TimeoutCache.new(x)
|
27
|
+
c[:a] = :b
|
28
|
+
c[:a].should == :b
|
29
|
+
sleep 3
|
30
|
+
c[:a].should be_nil
|
31
|
+
end
|
32
|
+
|
13
33
|
it "cannot be instantiated with a global timeout <= 0" do
|
14
34
|
[0, -1, -5].each do |i|
|
15
35
|
expect { TimeoutCache.new(i) }.to raise_error(ArgumentError)
|
@@ -30,6 +50,32 @@ describe TimeoutCache do
|
|
30
50
|
subject.expire_time(:a).should == t
|
31
51
|
end
|
32
52
|
|
53
|
+
context "if the :time value is neither a Time nor an Integer" do
|
54
|
+
it "accepts any object which responds to #to_time" do
|
55
|
+
s = double("")
|
56
|
+
s.should_receive(:to_time).and_return(Time.now + 2)
|
57
|
+
|
58
|
+
subject.set(:a, 5, :time => s)
|
59
|
+
subject[:a].should_not be_nil
|
60
|
+
sleep 3
|
61
|
+
subject[:a].should be_nil
|
62
|
+
end
|
63
|
+
|
64
|
+
it "accepts any object which responds to #to_int" do
|
65
|
+
s = double("")
|
66
|
+
s.should_receive(:to_int).any_number_of_times.and_return(2)
|
67
|
+
|
68
|
+
subject.set(:a, 5, :time => s)
|
69
|
+
subject[:a].should_not be_nil
|
70
|
+
sleep(s.to_int + 1)
|
71
|
+
subject[:a].should be_nil
|
72
|
+
end
|
73
|
+
|
74
|
+
it "throws ArgumentError if it can't get a time out of it" do
|
75
|
+
expect { subject.set(:a, :b, :time => "not a time") }.to raise_error(ArgumentError)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
33
79
|
# here be dragons. if it takes too long to execute these
|
34
80
|
# commands, the times won't match, so this test isn't deterministic
|
35
81
|
it "sets a default timeout time with no time value specified" do
|
@@ -86,6 +132,10 @@ describe TimeoutCache do
|
|
86
132
|
|
87
133
|
it "returns nil if nothing was deleted" do
|
88
134
|
subject[:no_such_key].should be_nil
|
135
|
+
|
136
|
+
subject[0] = 1
|
137
|
+
subject.delete(0)
|
138
|
+
subject.delete(0).should be_nil
|
89
139
|
end
|
90
140
|
|
91
141
|
it "returns the value deleted if the key is deleted" do
|
@@ -112,6 +162,12 @@ describe TimeoutCache do
|
|
112
162
|
subject[:a] = 1
|
113
163
|
subject.size.should_not == 0
|
114
164
|
end
|
165
|
+
|
166
|
+
it "ignores expired entries" do
|
167
|
+
subject.set(:a, :b, :time => 1)
|
168
|
+
sleep 2
|
169
|
+
subject.size.should == 0
|
170
|
+
end
|
115
171
|
end
|
116
172
|
|
117
173
|
describe "pruning" do
|
@@ -119,18 +175,26 @@ describe TimeoutCache do
|
|
119
175
|
subject.set(:a, 1, :time => 2)
|
120
176
|
subject.set(:b, 1, :time => 2)
|
121
177
|
sleep 3
|
122
|
-
subject.
|
178
|
+
subject.should_receive(:prune)
|
123
179
|
subject.get(:a)
|
124
|
-
subject.size.should == 0
|
125
180
|
end
|
126
181
|
|
127
182
|
it "does not happen when calling #get(key) when get(key) is not expired" do
|
128
183
|
subject.set(:a, 1, :time => 200)
|
129
184
|
subject.set(:b, 1, :time => 2)
|
130
185
|
sleep 3
|
131
|
-
subject.
|
186
|
+
subject.should_not_receive(:prune)
|
132
187
|
subject.get(:a)
|
133
|
-
|
188
|
+
end
|
189
|
+
|
190
|
+
it "happens on #size call" do
|
191
|
+
subject.should_receive(:prune)
|
192
|
+
subject.size
|
193
|
+
end
|
194
|
+
|
195
|
+
it "happens on an #empty? call" do
|
196
|
+
subject.should_receive(:prune)
|
197
|
+
subject.empty?
|
134
198
|
end
|
135
199
|
end
|
136
200
|
|
data/timeout_cache.gemspec
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: timeout_cache
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -43,6 +43,22 @@ dependencies:
|
|
43
43
|
- - ! '>='
|
44
44
|
- !ruby/object:Gem::Version
|
45
45
|
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: timecop
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
46
62
|
description: Simple time-based cache.
|
47
63
|
email:
|
48
64
|
- adam@aprescott.com
|
@@ -72,12 +88,18 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
88
|
- - ! '>='
|
73
89
|
- !ruby/object:Gem::Version
|
74
90
|
version: '0'
|
91
|
+
segments:
|
92
|
+
- 0
|
93
|
+
hash: -1494641401151669738
|
75
94
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
76
95
|
none: false
|
77
96
|
requirements:
|
78
97
|
- - ! '>='
|
79
98
|
- !ruby/object:Gem::Version
|
80
99
|
version: '0'
|
100
|
+
segments:
|
101
|
+
- 0
|
102
|
+
hash: -1494641401151669738
|
81
103
|
requirements: []
|
82
104
|
rubyforge_project:
|
83
105
|
rubygems_version: 1.8.24
|