glebpom-prop 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gemtest ADDED
File without changes
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Morten Primdahl
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,86 @@
1
+
2
+ = Prop
3
+
4
+ Prop is a simple gem for rate limiting requests of any kind. It allows you to configure hooks for registering certain actions, such that you can define thresholds, register usage and finally act on exceptions once thresholds get exceeded.
5
+
6
+ To get going with Prop you first define the read and write operations. These define how you write a registered request and how to read the number of requests for a given action. For example do something like the below in a Rails initializer:
7
+
8
+ Prop.read do |key|
9
+ Rails.cache.read(key)
10
+ end
11
+
12
+ Prop.write do |key, value|
13
+ Rails.cache.write(key, value)
14
+ end
15
+
16
+ You can choose to rely on a database or Moneta or Redis or whatever you'd like to use for transient storage. Prop does not do any sort of clean up of its key space, so you would have to implement that manually should you be using anything but an LRU cache.
17
+
18
+ Once the read and write operations are defined, you can optionally define some preconfigured default thresholds. If for example, you want to have a threshold on accepted emails per hour from a given user, you could define a threshold and interval (in seconds) for this like so:
19
+
20
+ Prop.defaults(:mails_per_hour, :threshold => 100, :interval => 1.hour)
21
+
22
+ You can now put the throttle to work with this values, by passing the "handle" to the respective methods in Prop:
23
+
24
+ # Throws Prop::RateLimitExceededError if the threshold/interval has been reached
25
+ Prop.throttle!(:mails_per_hour)
26
+
27
+ # Returns true if the threshold/interval has been reached
28
+ Prop.throttled?(:mails_per_hour)
29
+
30
+ # Sets the throttle "count" to 0
31
+ Prop.reset(:mails_per_hour)
32
+
33
+ # Returns the value of this throttle, usually a count, but see below for more
34
+ Prop.query(:mails_per_hour)
35
+
36
+ In many cases you will want to tie a specific key to a defined throttle, for example you can scope the throttling to a specific sender rather than running a global "mails per hour" throttle:
37
+
38
+ Prop.throttle!(:mails_per_hour, mail.from)
39
+ Prop.throttled?(:mails_per_hour, mail.from)
40
+ Prop.reset(:mails_per_hour, mail.from)
41
+ Prop.query(:mails_per_hour, mail.from)
42
+
43
+ The throttle scope can also be an array of values, e.g.:
44
+
45
+ Prop.throttle!(:mails_per_hour, [ account.id, mail.from ])
46
+
47
+ If the throttle! method gets called more than "threshold" times within "interval in seconds" for a given handle and key combination, Prop throws a Prop::RateLimitExceededError. This exception contains a "handle" reference, which is handy when you are using Prop in multiple locations and want to be able to differentiate further up the stack. For example, in Rails you can use this in e.g. ApplicationController:
48
+
49
+ THROTTLE_MESSAGES = Hash.new("Throttle exceeded")
50
+ THROTTLE_MESSAGES[:login] = "Too many invalid login attempts. Try again later."
51
+
52
+ rescue_from Prop::RateLimitExceededError do |exception|
53
+ render :status => 403, :message => THROTTLE_MESSAGES[exception.handle]
54
+ end
55
+
56
+ You can chose to override the threshold for a given key:
57
+
58
+ Prop.throttle!(:mails_per_hour, mail.from, :threshold => account.mail_throttle_threshold)
59
+
60
+ When the threshold are invoked without argument, the key is nil and as such a scope of its own, i.e. these are equivalent:
61
+
62
+ Prop.throttle!(:mails_per_hour)
63
+ Prop.throttle!(:mails_per_hour, nil)
64
+
65
+ The default (and smallest possible) increment is 1, you can set that to any integer value using :increment which is handy for building time based throttles:
66
+
67
+ Prop.setup(:execute_time, :threshold => 10, :interval => 1.minute)
68
+ Prop.throttle!(:execute_time, account.id, :increment => (Benchmark.realtime { execute }).to_i)
69
+
70
+ == How it works
71
+
72
+ Prop uses the interval to define a window of time using simple div arithmetic. This means that it's a worst case throttle that will allow up to 2 times the specified requests within the specified interval.
73
+
74
+ == Note on Patches/Pull Requests
75
+
76
+ * Fork the project.
77
+ * Make your feature addition or bug fix.
78
+ * Add tests for it. This is important so I don't break it in a
79
+ future version unintentionally.
80
+ * Commit, do not mess with rakefile, version, or history.
81
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
82
+ * Send me a pull request. Bonus points for topic branches.
83
+
84
+ == Copyright
85
+
86
+ Copyright (c) 2010 Morten Primdahl. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,54 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "prop"
8
+ gem.summary = %Q{Puts a cork in their requests}
9
+ gem.description = %Q{A gem for implementing rate limiting}
10
+ gem.email = "morten@zendesk.com"
11
+ gem.homepage = "http://github.com/morten/prop"
12
+ gem.authors = ["Morten Primdahl"]
13
+ gem.add_development_dependency "shoulda", ">= 0"
14
+ gem.add_development_dependency "mocha", ">= 0.9.8"
15
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
16
+ end
17
+ Jeweler::GemcutterTasks.new
18
+ rescue LoadError
19
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
20
+ end
21
+
22
+ require 'rake/testtask'
23
+ Rake::TestTask.new(:test) do |test|
24
+ test.libs << 'lib' << 'test'
25
+ test.pattern = 'test/**/test_*.rb'
26
+ test.verbose = true
27
+ end
28
+
29
+ begin
30
+ require 'rcov/rcovtask'
31
+ Rcov::RcovTask.new do |test|
32
+ test.libs << 'test'
33
+ test.pattern = 'test/**/test_*.rb'
34
+ test.verbose = true
35
+ end
36
+ rescue LoadError
37
+ task :rcov do
38
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
39
+ end
40
+ end
41
+
42
+ task :test => :check_dependencies
43
+
44
+ task :default => :test
45
+
46
+ require 'rake/rdoctask'
47
+ Rake::RDocTask.new do |rdoc|
48
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
49
+
50
+ rdoc.rdoc_dir = 'rdoc'
51
+ rdoc.title = "prop #{version}"
52
+ rdoc.rdoc_files.include('README*')
53
+ rdoc.rdoc_files.include('lib/**/*.rb')
54
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.5.1
data/lib/prop.rb ADDED
@@ -0,0 +1,116 @@
1
+ require 'digest/md5'
2
+
3
+ class Object
4
+ def define_prop_class_method(name, &blk)
5
+ (class << self; self; end).instance_eval { define_method(name, &blk) }
6
+ end
7
+ end
8
+
9
+ class Prop
10
+ class RateLimitExceededError < RuntimeError
11
+ attr_accessor :handle, :retry_after
12
+
13
+ def self.create(handle, key, threshold)
14
+ error = new("#{handle} threshold of #{threshold} exceeded for key '#{key}'")
15
+ error.handle = handle
16
+ error.retry_after = threshold - Time.now.to_i % threshold if threshold > 0
17
+ raise error
18
+ end
19
+ end
20
+
21
+ class << self
22
+ attr_accessor :handles, :reader, :writer
23
+
24
+ def read(&blk)
25
+ self.reader = blk
26
+ end
27
+
28
+ def write(&blk)
29
+ self.writer = blk
30
+ end
31
+
32
+ def increment(&blk)
33
+ self.incrementer = blk
34
+ end
35
+
36
+ def incrementer=(value)
37
+ @incrementer = value
38
+ end
39
+
40
+ def incrementer
41
+ @incrementer ? Proc.new { |key, inc| @incrementer.call(key, inc) } :
42
+ Proc.new { |key, inc| self.writer.call(key, (self.reader.call(key) || 0).to_i + inc) }
43
+ end
44
+
45
+ def defaults(handle, defaults)
46
+ raise RuntimeError.new("Invalid threshold setting") unless defaults[:threshold].to_i > 0
47
+ raise RuntimeError.new("Invalid interval setting") unless defaults[:interval].to_i > 0
48
+
49
+ self.handles ||= {}
50
+ self.handles[handle] = defaults
51
+ end
52
+
53
+ def throttle!(handle, key = nil, options = {})
54
+ options = sanitized_prop_options(handle, key, options)
55
+ cache_key = sanitized_prop_key(key, options[:interval])
56
+ counter = reader.call(cache_key).to_i
57
+
58
+ incrementer.call(cache_key, [ 1, options[:increment].to_i ].max)
59
+
60
+ if counter >= options[:threshold]
61
+ raise Prop::RateLimitExceededError.create(handle, normalize_cache_key(key), options[:threshold])
62
+ end
63
+ end
64
+
65
+ def throttled?(handle, key = nil, options = {})
66
+ options = sanitized_prop_options(handle, key, options)
67
+ cache_key = sanitized_prop_key(key, options[:interval])
68
+
69
+ reader.call(cache_key).to_i >= options[:threshold]
70
+ end
71
+
72
+ def reset(handle, key = nil, options = {})
73
+ options = sanitized_prop_options(handle, key, options)
74
+ cache_key = sanitized_prop_key(key, options[:interval])
75
+
76
+ writer.call(cache_key, 0)
77
+ end
78
+
79
+ def query(handle, key = nil, options = {})
80
+ options = sanitized_prop_options(handle, key, options)
81
+ cache_key = sanitized_prop_key(key, options[:interval])
82
+
83
+ reader.call(cache_key).to_i
84
+ end
85
+
86
+ private
87
+
88
+ # Builds the expiring cache key
89
+ def sanitized_prop_key(key, interval)
90
+ window = (Time.now.to_i / interval)
91
+ cache_key = "#{normalize_cache_key(key)}/#{ window }"
92
+ "prop/#{Digest::MD5.hexdigest(cache_key)}"
93
+ end
94
+
95
+ # Sanitizes the option set and sets defaults
96
+ def sanitized_prop_options(handle, key, options)
97
+ defaults = (handles || {})[handle] || {}
98
+ return {
99
+ :key => normalize_cache_key(key),
100
+ :increment => defaults[:increment],
101
+ :threshold => defaults[:threshold].to_i,
102
+ :interval => defaults[:interval].to_i
103
+ }.merge(options)
104
+ end
105
+
106
+ # Simple key expansion only supports arrays and primitives
107
+ def normalize_cache_key(key)
108
+ if key.is_a?(Array)
109
+ key.map { |part| normalize_cache_key(part) }.join('/')
110
+ else
111
+ key.to_s
112
+ end
113
+ end
114
+
115
+ end
116
+ end
data/prop.gemspec ADDED
@@ -0,0 +1,56 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{glebpom-prop}
8
+ s.version = "0.0.1"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Gleb Pomykalov"]
12
+ s.date = %q{2011-02-03}
13
+ s.description = %q{A gem for implementing rate limiting}
14
+ s.email = %q{gleb.pomykalov@skype.net}
15
+ s.extra_rdoc_files = [
16
+ "LICENSE",
17
+ "README.rdoc"
18
+ ]
19
+ s.files = [
20
+ ".document",
21
+ ".gemtest",
22
+ "LICENSE",
23
+ "README.rdoc",
24
+ "Rakefile",
25
+ "VERSION",
26
+ "lib/prop.rb",
27
+ "prop.gemspec",
28
+ "test/helper.rb",
29
+ "test/test_prop.rb"
30
+ ]
31
+ s.homepage = %q{http://github.com/glebpom/prop}
32
+ s.require_paths = ["lib"]
33
+ s.rubygems_version = %q{1.3.7}
34
+ s.summary = %q{Puts a cork in their requests}
35
+ s.test_files = [
36
+ "test/helper.rb",
37
+ "test/test_prop.rb"
38
+ ]
39
+
40
+ if s.respond_to? :specification_version then
41
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
42
+ s.specification_version = 3
43
+
44
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
45
+ s.add_development_dependency(%q<shoulda>, [">= 0"])
46
+ s.add_development_dependency(%q<mocha>, [">= 0.9.8"])
47
+ else
48
+ s.add_dependency(%q<shoulda>, [">= 0"])
49
+ s.add_dependency(%q<mocha>, [">= 0.9.8"])
50
+ end
51
+ else
52
+ s.add_dependency(%q<shoulda>, [">= 0"])
53
+ s.add_dependency(%q<mocha>, [">= 0.9.8"])
54
+ end
55
+ end
56
+
data/test/helper.rb ADDED
@@ -0,0 +1,12 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+ require 'time'
6
+
7
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
8
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
9
+ require 'prop'
10
+
11
+ class Test::Unit::TestCase
12
+ end
data/test/test_prop.rb ADDED
@@ -0,0 +1,152 @@
1
+ require 'helper'
2
+
3
+ class TestProp < Test::Unit::TestCase
4
+
5
+ context "Prop" do
6
+ setup do
7
+ @store = {}
8
+ Prop.read { |key| @store[key] }
9
+ Prop.write { |key, value| @store[key] = value }
10
+
11
+ @start = Time.now
12
+ Time.stubs(:now).returns(@start)
13
+ end
14
+
15
+ {"with incrementer" => lambda { Prop.increment { |key, inc| @store[key] ? @store[key] += inc : @store[key] = inc } },
16
+ "without incrementer" => lambda {} }.each do |desc, setup_block_for_context|
17
+ context desc do
18
+ setup do
19
+ instance_eval(&setup_block_for_context)
20
+ end
21
+
22
+ teardown do
23
+ Prop.instance_variable_set("@incrementer", nil)
24
+ end
25
+
26
+ context "#defaults" do
27
+ should "raise errors on invalid configuation" do
28
+ assert_raises(RuntimeError) do
29
+ Prop.defaults :hello_there, :threshold => 20, :interval => 'hello'
30
+ end
31
+
32
+ assert_raises(RuntimeError) do
33
+ Prop.defaults :hello_there, :threshold => 'wibble', :interval => 100
34
+ end
35
+ end
36
+
37
+ should "result in a default handle" do
38
+ Prop.defaults :hello_there, :threshold => 4, :interval => 10
39
+ 4.times do |i|
40
+ assert_equal (i + 1), Prop.throttle!(:hello_there, 'some key')
41
+ end
42
+
43
+ assert_raises(Prop::RateLimitExceededError) { Prop.throttle!(:hello_there, 'some key') }
44
+ assert_equal 5, Prop.throttle!(:hello_there, 'some key', :threshold => 20)
45
+ end
46
+
47
+ should "create a handle accepts various cache key types" do
48
+ Prop.defaults :hello_there, :threshold => 4, :interval => 10
49
+ assert_equal 1, Prop.throttle!(:hello_there, 5)
50
+ assert_equal 2, Prop.throttle!(:hello_there, 5)
51
+ assert_equal 1, Prop.throttle!(:hello_there, '6')
52
+ assert_equal 2, Prop.throttle!(:hello_there, '6')
53
+ assert_equal 1, Prop.throttle!(:hello_there, [ 5, '6' ])
54
+ assert_equal 2, Prop.throttle!(:hello_there, [ 5, '6' ])
55
+ end
56
+ end
57
+
58
+ context "#reset" do
59
+ setup do
60
+ Prop.defaults :hello, :threshold => 10, :interval => 10
61
+
62
+ 5.times do |i|
63
+ assert_equal (i + 1), Prop.throttle!(:hello)
64
+ end
65
+ end
66
+
67
+ should "set the correct counter to 0" do
68
+ Prop.throttle!(:hello, 'wibble')
69
+ Prop.throttle!(:hello, 'wibble')
70
+
71
+ Prop.reset(:hello)
72
+ assert_equal 1, Prop.throttle!(:hello)
73
+
74
+ assert_equal 3, Prop.throttle!(:hello, 'wibble')
75
+ Prop.reset(:hello, 'wibble')
76
+ assert_equal 1, Prop.throttle!(:hello, 'wibble')
77
+ end
78
+ end
79
+
80
+ context "#throttled?" do
81
+ should "return true once the threshold has been reached" do
82
+ Prop.defaults(:hello, :threshold => 2, :interval => 10)
83
+ Prop.throttle!(:hello)
84
+ assert !Prop.throttled?(:hello)
85
+ Prop.throttle!(:hello)
86
+ assert Prop.throttled?(:hello)
87
+ end
88
+ end
89
+
90
+ context "#throttle!" do
91
+ should "increment counter correctly" do
92
+ 3.times do |i|
93
+ assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
94
+ end
95
+ end
96
+
97
+ should "reset counter when time window is passed" do
98
+ 3.times do |i|
99
+ assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
100
+ end
101
+
102
+ Time.stubs(:now).returns(@start + 20)
103
+
104
+ 3.times do |i|
105
+ assert_equal (i + 1), Prop.throttle!(:hello, nil, :threshold => 10, :interval => 10)
106
+ end
107
+ end
108
+
109
+ should "not increment the counter beyond the threshold" do
110
+ Prop.defaults(:hello, :threshold => 5, :interval => 1)
111
+ 10.times do |i|
112
+ Prop.throttle!(:hello) rescue nil
113
+ end
114
+
115
+ assert_equal 5, Prop.query(:hello)
116
+ end
117
+
118
+ should "support custom increments" do
119
+ Prop.defaults(:hello, :threshold => 100, :interval => 10)
120
+
121
+ Prop.throttle!(:hello)
122
+ Prop.throttle!(:hello)
123
+
124
+ assert_equal 2, Prop.query(:hello)
125
+
126
+ Prop.throttle!(:hello, nil, :increment => 48)
127
+
128
+ assert_equal 50, Prop.query(:hello)
129
+ end
130
+
131
+ should "raise Prop::RateLimitExceededError when the threshold is exceeded" do
132
+ 5.times do |i|
133
+ Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
134
+ end
135
+ assert_raises(Prop::RateLimitExceededError) do
136
+ Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
137
+ end
138
+
139
+ begin
140
+ Prop.throttle!(:hello, nil, :threshold => 5, :interval => 10)
141
+ fail
142
+ rescue Prop::RateLimitExceededError => e
143
+ assert_equal :hello, e.handle
144
+ assert_equal "hello threshold of 5 exceeded for key ''", e.message
145
+ assert e.retry_after
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: glebpom-prop
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Gleb Pomykalov
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-02-03 00:00:00 Z
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: shoulda
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ none: false
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ hash: 3
29
+ segments:
30
+ - 0
31
+ version: "0"
32
+ type: :development
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: mocha
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ none: false
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ hash: 43
43
+ segments:
44
+ - 0
45
+ - 9
46
+ - 8
47
+ version: 0.9.8
48
+ type: :development
49
+ version_requirements: *id002
50
+ description: A gem for implementing rate limiting
51
+ email: gleb.pomykalov@skype.net
52
+ executables: []
53
+
54
+ extensions: []
55
+
56
+ extra_rdoc_files:
57
+ - LICENSE
58
+ - README.rdoc
59
+ files:
60
+ - .document
61
+ - .gemtest
62
+ - LICENSE
63
+ - README.rdoc
64
+ - Rakefile
65
+ - VERSION
66
+ - lib/prop.rb
67
+ - prop.gemspec
68
+ - test/helper.rb
69
+ - test/test_prop.rb
70
+ homepage: http://github.com/glebpom/prop
71
+ licenses: []
72
+
73
+ post_install_message:
74
+ rdoc_options: []
75
+
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ none: false
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ hash: 3
84
+ segments:
85
+ - 0
86
+ version: "0"
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ hash: 3
93
+ segments:
94
+ - 0
95
+ version: "0"
96
+ requirements: []
97
+
98
+ rubyforge_project:
99
+ rubygems_version: 1.8.6
100
+ signing_key:
101
+ specification_version: 3
102
+ summary: Puts a cork in their requests
103
+ test_files:
104
+ - test/helper.rb
105
+ - test/test_prop.rb