vop 0.3.1 → 0.3.4

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/Gemfile +2 -1
  4. data/Gemfile.lock +34 -33
  5. data/README.md +184 -2
  6. data/bin/console +17 -0
  7. data/bin/setup +8 -0
  8. data/bin/vop.sh +6 -2
  9. data/exe/vop +3 -25
  10. data/lib/boot.rb +3 -0
  11. data/lib/core/meta/commands/new_command.rb +1 -0
  12. data/lib/core/meta/commands/new_plugin.rb +47 -0
  13. data/lib/core/meta/commands/search_gems_for_plugins.rb +38 -0
  14. data/lib/core/meta/commands/search_path.rb +6 -0
  15. data/lib/core/meta/commands/set.rb +12 -0
  16. data/lib/core/meta/commands/show.rb +6 -0
  17. data/lib/core/meta/commands/show_config.rb +9 -0
  18. data/lib/core/meta/commands/who_provides.rb +6 -0
  19. data/lib/core/meta/meta.plugin +1 -0
  20. data/lib/core/shell/commands/change_loglevel.rb +6 -0
  21. data/lib/{vop/plugins/core → core/shell}/commands/clear_context.rb +0 -0
  22. data/lib/core/shell/commands/edit.rb +12 -0
  23. data/lib/core/shell/commands/help.rb +49 -0
  24. data/lib/core/shell/commands/reset.rb +4 -0
  25. data/lib/{vop/plugins/core → core/shell}/commands/show_context.rb +0 -0
  26. data/lib/core/shell/commands/source.rb +21 -0
  27. data/lib/{vop/plugins/core/helpers/plugin_loader/plugin_syntax.rb → core/shell/shell.plugin} +0 -0
  28. data/lib/core/structure/commands/collect_contributions.rb +46 -0
  29. data/lib/core/structure/commands/disable_contributor.rb +16 -0
  30. data/lib/core/structure/commands/generate_entity_commands.rb +52 -0
  31. data/lib/core/structure/commands/generate_invalidation_commands.rb +26 -0
  32. data/lib/core/structure/commands/list_commands.rb +11 -0
  33. data/lib/core/structure/commands/list_contributors.rb +10 -0
  34. data/lib/core/structure/commands/list_entities.rb +3 -0
  35. data/lib/core/structure/commands/list_plugins.rb +3 -0
  36. data/lib/core/structure/commands/register_contributor.rb +14 -0
  37. data/lib/core/structure/structure.plugin +4 -0
  38. data/lib/vop/objects/chain.rb +25 -0
  39. data/lib/vop/objects/command.rb +86 -0
  40. data/lib/vop/objects/command_param.rb +39 -0
  41. data/lib/vop/objects/entities.rb +21 -0
  42. data/lib/vop/objects/entity.rb +75 -0
  43. data/lib/vop/objects/entity_definition.rb +33 -0
  44. data/lib/vop/objects/filter.rb +48 -0
  45. data/lib/vop/objects/plugin.rb +208 -0
  46. data/lib/vop/objects/request.rb +73 -0
  47. data/lib/vop/objects/response.rb +17 -0
  48. data/lib/vop/objects/thing_with_params.rb +17 -0
  49. data/lib/vop/{command_loader.rb → parts/command_loader.rb} +8 -12
  50. data/lib/vop/parts/dependency_resolver.rb +56 -0
  51. data/lib/vop/parts/entity_loader.rb +46 -0
  52. data/lib/vop/parts/executor.rb +155 -0
  53. data/lib/vop/parts/filter_loader.rb +41 -0
  54. data/lib/vop/parts/plugin_finder.rb +46 -0
  55. data/lib/vop/parts/plugin_loader.rb +72 -0
  56. data/lib/vop/shell/shell.rb +221 -0
  57. data/lib/vop/shell/shell_formatter.rb +110 -0
  58. data/lib/vop/shell/shell_input.rb +14 -0
  59. data/lib/vop/shell/shell_input_readline.rb +20 -0
  60. data/lib/vop/shell/shell_input_testable.rb +27 -0
  61. data/lib/vop/syntax/command_syntax.rb +90 -0
  62. data/lib/vop/syntax/entity_syntax.rb +35 -0
  63. data/lib/vop/syntax/filter_syntax.rb +11 -0
  64. data/lib/vop/syntax/plugin_syntax.rb +55 -0
  65. data/lib/vop/util/errors.rb +45 -0
  66. data/lib/vop/util/pluralizer.rb +26 -0
  67. data/lib/vop/util/worker.rb +24 -0
  68. data/lib/vop/version.rb +1 -1
  69. data/lib/vop/vop.rb +216 -0
  70. data/lib/vop.rb +16 -229
  71. data/vop.gemspec +18 -15
  72. metadata +95 -63
  73. data/bin/vop.rb +0 -28
  74. data/lib/vop/command.rb +0 -168
  75. data/lib/vop/entity.rb +0 -61
  76. data/lib/vop/loader.rb +0 -35
  77. data/lib/vop/plugin.rb +0 -141
  78. data/lib/vop/plugin_loader.rb +0 -88
  79. data/lib/vop/plugins/core/commands/collect_contributions.rb +0 -31
  80. data/lib/vop/plugins/core/commands/edit.rb +0 -12
  81. data/lib/vop/plugins/core/commands/help.rb +0 -38
  82. data/lib/vop/plugins/core/commands/identity.rb +0 -4
  83. data/lib/vop/plugins/core/commands/list_contributors.rb +0 -8
  84. data/lib/vop/plugins/core/commands/list_entities.rb +0 -3
  85. data/lib/vop/plugins/core/commands/pry.rb +0 -9
  86. data/lib/vop/plugins/core/commands/reset.rb +0 -5
  87. data/lib/vop/plugins/core/commands/source.rb +0 -5
  88. data/lib/vop/plugins/core/commands/system_call.rb +0 -5
  89. data/lib/vop/plugins/core/core.plugin +0 -4
  90. data/lib/vop/plugins/core/helpers/command_loader/command_syntax.rb +0 -45
  91. data/lib/vop/plugins/core/helpers/command_loader/contributions.rb +0 -28
  92. data/lib/vop/plugins/core/helpers/command_loader/entities.rb +0 -57
  93. data/lib/vop/plugins/core/helpers/helper.rb +0 -3
  94. data/lib/vop/plugins/meta/commands/add_search_path.rb +0 -6
  95. data/lib/vop/plugins/meta/commands/delete_plugin.rb +0 -13
  96. data/lib/vop/plugins/meta/commands/list_commands.rb +0 -17
  97. data/lib/vop/plugins/meta/commands/list_plugins.rb +0 -8
  98. data/lib/vop/plugins/meta/commands/new_command.rb +0 -14
  99. data/lib/vop/plugins/meta/commands/new_plugin.rb +0 -25
  100. data/lib/vop/plugins/meta/commands/show_search_path.rb +0 -3
  101. data/lib/vop/plugins/meta/commands/who_provides.rb +0 -5
  102. data/lib/vop/plugins/meta/meta.plugin +0 -1
  103. data/lib/vop/plugins/ssh/commands/scp.rb +0 -11
  104. data/lib/vop/plugins/ssh/commands/ssh.rb +0 -19
  105. data/lib/vop/plugins/ssh/ssh.plugin +0 -1
  106. data/lib/vop/shell/backend.rb +0 -28
  107. data/lib/vop/shell/base_shell.rb +0 -112
  108. data/lib/vop/shell/formatter.rb +0 -46
  109. data/lib/vop/shell/vop_shell_backend.rb +0 -257
  110. data/lib/vop/shell.rb +0 -52
data/lib/vop/vop.rb ADDED
@@ -0,0 +1,216 @@
1
+ require "pathname"
2
+ require "pp"
3
+ require "logger"
4
+
5
+ require_relative "parts/plugin_finder"
6
+ require_relative "parts/plugin_loader"
7
+ require_relative "parts/dependency_resolver"
8
+ require_relative "objects/request"
9
+ require_relative "objects/entity"
10
+ require_relative "objects/entities"
11
+ require_relative "util/errors"
12
+ require_relative "util/pluralizer"
13
+ require_relative "util/worker"
14
+
15
+ module Vop
16
+
17
+ $logger = Logger.new(STDOUT)
18
+
19
+ class Vop
20
+
21
+ attr_reader :plugins
22
+ attr_reader :commands
23
+ attr_reader :entities
24
+
25
+ attr_reader :filters
26
+ attr_reader :filter_chain
27
+
28
+ attr_reader :finder, :loader, :sorter
29
+
30
+ def initialize(options = {})
31
+ @options = {
32
+ config_path: "/etc/vop",
33
+ log_level: Logger::INFO
34
+ }.merge(options)
35
+ $logger.level = @options[:log_level]
36
+
37
+ @finder = PluginFinder.new
38
+ @loader = PluginLoader.new(self)
39
+ @sorter = DependencyResolver.new(self)
40
+
41
+ _reset
42
+ end
43
+
44
+ def clear
45
+ @plugins = []
46
+ @commands = {}
47
+ @entities = {}
48
+ @filters = {}
49
+ @filter_chain = []
50
+ @hooks = Hash.new { |h,k| h[k] = [] }
51
+ end
52
+
53
+ def _reset
54
+ clear
55
+ load
56
+ end
57
+
58
+ def to_s
59
+ "Vop (#{@plugins.size} plugins)"
60
+ end
61
+
62
+ def inspect
63
+ {
64
+ plugins: @plugins.map(&:name)
65
+ }.to_json()
66
+ end
67
+
68
+ def plugin(name)
69
+ result = @plugins.select { |x| x.name == name }.first
70
+ raise "no such plugin: #{name}" if result.nil?
71
+ result
72
+ end
73
+
74
+ def lib_path
75
+ Pathname.new(File.join(File.dirname(__FILE__), "..")).realpath
76
+ end
77
+
78
+ def core_location
79
+ File.join(lib_path, "core")
80
+ end
81
+
82
+ def plugin_locations
83
+ result = []
84
+
85
+ # during development, we might find checkouts for "plugins" and "services"
86
+ # next to the core
87
+ vop_dir = Pathname.new(File.join(lib_path, "..", "..")).realpath
88
+ unless vop_dir.to_s.start_with? "/usr"
89
+ %w|plugins services|.each do |thing|
90
+ sibling_dir = File.join(vop_dir, thing)
91
+ result << sibling_dir
92
+ end
93
+ end
94
+
95
+ # for distribution packages (?)
96
+ result << "/usr/lib/vop-plugins"
97
+
98
+ # an extra path might have been passed in the options
99
+ if @options.has_key? :plugin_path
100
+ result << @options[:plugin_path]
101
+ end
102
+
103
+ result
104
+ end
105
+
106
+ def config_path
107
+ @options[:config_path]
108
+ end
109
+
110
+ def plugin_config_path
111
+ @plugin_config_path ||= File.join(config_path, "plugins.d")
112
+ end
113
+
114
+ def load_from(locations, load_options = {})
115
+ found = finder.find(locations)
116
+ plugins = loader.load(found, load_options)
117
+ new_plugins = sorter.sort(plugins.loaded)
118
+
119
+ new_plugins.each do |plugin|
120
+ plugin.init
121
+ self << plugin
122
+ end
123
+
124
+ $logger.debug "loaded #{new_plugins.size} plugins from #{locations}"
125
+ $logger.debug plugins.loaded.map(&:name)
126
+ end
127
+
128
+ def load
129
+ load_from(core_location, { core: true })
130
+ load_from(plugin_locations)
131
+ load_from(search_path)
132
+
133
+ call_global_hook :loading_finished
134
+
135
+ $logger.info "init complete : #{@plugins.size} plugins, #{@commands.size} commands"
136
+ end
137
+
138
+ def <<(stuff)
139
+ if stuff.is_a? Array
140
+ stuff.each do |thing|
141
+ self << thing
142
+ end
143
+ else
144
+ if stuff.is_a? Plugin
145
+ @plugins << stuff
146
+ elsif stuff.is_a? EntityDefinition
147
+ entity = stuff
148
+ @entities[entity.short_name] = stuff
149
+ elsif stuff.is_a? Command
150
+ command = stuff
151
+ unless command.dont_register
152
+ $logger.debug "registering #{command.name}"
153
+ if @commands.keys.include? command.short_name
154
+ $logger.warn "overriding previous declaration of #{command.short_name}"
155
+ end
156
+ @commands[command.short_name] = stuff
157
+
158
+ self.class.send(:define_method, command.short_name) do |*args, &block|
159
+ ruby_args = args.length > 0 ? args[0] : {}
160
+ # TODO we might want to do this only if there's a block param defined
161
+ if block
162
+ ruby_args["block"] = block
163
+ end
164
+ self.execute(command.short_name, ruby_args)
165
+ end
166
+ end
167
+ elsif stuff.is_a? Filter
168
+ short_name = stuff.short_name
169
+ @filters[short_name] = stuff
170
+ @filter_chain.unshift short_name
171
+ else
172
+ raise Errors::LoadError.new "unexpected type '#{stuff.class}'"
173
+ end
174
+ end
175
+ end
176
+
177
+ def hook(hook_sym, &block)
178
+ @hooks[hook_sym] << block
179
+ end
180
+
181
+ def call_global_hook(hook_sym, payload = {})
182
+ @hooks[hook_sym].each do |h|
183
+ h.call(payload)
184
+ end
185
+ end
186
+
187
+ def execute_request(request)
188
+ call_global_hook(:before_execute, { request: request })
189
+ begin
190
+ response = request.execute()
191
+ rescue => e
192
+ response = ::Vop::Response.new(nil, {})
193
+ response.status = "error"
194
+ raise e
195
+ ensure
196
+ call_global_hook(:after_execute, { request: request, response: response })
197
+ end
198
+
199
+ #$logger.debug "executed : #{request.command.name}, response : #{response.pretty_inspect}"
200
+ response
201
+ end
202
+
203
+ def execute(command_name, param_values = {}, extra = {})
204
+ request = Request.new(self, command_name, param_values, extra)
205
+ response = execute_request(request)
206
+
207
+ response.result
208
+ end
209
+
210
+ def execute_async(request)
211
+ AsyncExecutorWorker.perform_async(request.to_json)
212
+ end
213
+
214
+ end
215
+
216
+ end
data/lib/vop.rb CHANGED
@@ -1,242 +1,29 @@
1
- require 'pp'
2
- require 'logger'
3
- require 'pathname'
1
+ require "pathname"
4
2
 
5
- require 'vop/version'
6
- require 'vop/plugin_loader'
7
- require 'active_support/inflector'
3
+ require_relative "vop/version"
4
+ require_relative "vop/shell/shell"
5
+ require_relative "vop/vop"
8
6
 
9
7
  module Vop
10
8
 
11
- VOP_ROOT = Pathname.new(File.join(File.dirname(__FILE__), '..')).realpath
12
- CORE_PLUGIN_PATH = Pathname.new(File.join(File.dirname(__FILE__), 'vop', 'plugins')).realpath
13
- #CONFIG_PATH = '/etc/vop'
14
- #PLUGIN_CONFIG_PATH = File.join(CONFIG_PATH, 'plugins.d')
15
-
16
- class Vop
17
-
18
- DEFAULTS = {
19
- :search_path => [
20
- File.join(VOP_ROOT, '..', 'plugins/standard'),
21
- File.join(VOP_ROOT, '..', 'plugins/extended')
22
- ],
23
- :command_dir_name => 'commands',
24
- :plugin_config => {
25
-
26
- }
27
- }
28
-
29
- attr_reader :config
30
-
31
- attr_reader :plugins
32
- attr_reader :commands
33
-
34
- def initialize(options = {})
35
- at_exit {
36
- self.shutdown
37
- }
38
-
39
- @version = ::Vop::VERSION
40
-
41
- @config = DEFAULTS
42
- @config.merge! options
43
-
44
- $logger = Logger.new(STDOUT)
45
- $logger.level = options['--verbose'] ? Logger::DEBUG : Logger::INFO
46
-
47
- _reset
48
-
49
- $logger.info "virtualop (#{@version}) init complete."
50
- $logger.info "hello."
51
- end
52
-
53
- def _pry
54
- binding.pry
55
- end
56
-
57
- def _reset
58
- $logger.debug "loading..."
59
-
60
- load_plugins
61
-
62
- $logger.info "loaded #{@commands.size} commands from #{@plugins.size} plugins"
63
- end
64
-
65
- def _search_path
66
- [ CORE_PLUGIN_PATH ] + config[:search_path]
67
- end
68
-
69
- def inspect
70
- chunk_size = 25
71
- plugin_string = @plugins.keys.sort[0..chunk_size-1].join(' ')
72
- if @plugins.length > chunk_size
73
- plugin_string += " + #{@plugins.length - chunk_size} more"
74
- end
75
- "vop #{@version} (#{plugin_string})"
76
- end
77
-
78
- def eat(command)
79
- @commands[command.short_name] = command
80
-
81
- self.class.send(:define_method, command.short_name) do |*args|
82
- ruby_args = args.length > 0 ? args[0] : {}
83
- self.execute(command.short_name, ruby_args)
84
- end
85
- end
86
-
87
- def load_plugins
88
- @plugins = {}
89
- @commands = {}
90
- @hooks = Hash.new { |h,k| h[k] = [] }
91
-
92
- # step 1 : read plugins from all existing source dirs
93
- candidates = _search_path
94
- search_path = candidates.select { |path| File.exists? path }
95
- search_path.each do |path|
96
- PluginLoader.read(self, path)
97
- end
98
-
99
- # step 2 : activate plugins (in the right order)
100
- ordered_plugins.each do |plugin|
101
- plugin.init
102
- end
103
-
104
- # step 3 : expand entities
105
- @plugins['core'].state[:entities].each do |entity|
106
- entity_name = entity[:name]
107
- entity_command = @commands[entity_name]
108
- list_command_name = "list_#{entity_name.pluralize(42)}"
109
- $logger.debug "generating #{list_command_name}"
110
- list_command = Command.new(entity_command.plugin, list_command_name)
111
-
112
- if entity[:options][:on]
113
- list_command.params << {
114
- :name => entity[:options][:on],
115
- :multi => false,
116
- :mandatory => true,
117
- :default_param => true
118
- }
119
- end
120
- list_command.block = entity_command.param(entity[:key])[:lookup]
121
- eat(list_command)
122
- # TODO add pseudo source code so that `source <list_command_name>` works
123
- end
124
-
125
- # TODO add pre-flight hook so that plugins can attach logic to execute here
126
- end
127
-
128
- def resolve(plugin, resolved, unresolved, level = 0)
129
- unresolved << plugin.name
130
-
131
- plugin.dependencies.each do |dep|
132
- unless resolved.include? dep
133
- if unresolved.include? dep
134
- raise "running in circles #{plugin.name} -> #{dep}"
135
- else
136
- unless @plugins.has_key? dep
137
- raise "missing dependency: #{plugin.name} depends on #{dep}"
138
- end
139
- dependency = @plugins[dep]
140
- resolve(dependency, resolved, unresolved, level + 1)
141
- end
142
- end
143
- end
144
- resolved << plugin.name
145
- end
146
-
147
- def ordered_plugins
148
- root_plugin = Plugin.new(self, '__root__', nil)
149
- @plugins.values.each do |plugin|
150
- root_plugin.dependencies << plugin.name
151
- end
152
- resolved = []
153
- unresolved = []
154
-
155
- resolve(root_plugin, resolved, unresolved)
156
- resolved.delete_if { |x| x == root_plugin.name }
157
-
158
- resolved.map { |x| @plugins[x] }
159
- end
160
-
161
- def command(name)
162
- unless commands.has_key?(name)
163
- raise "no such command : #{name}"
164
- end
165
-
166
- commands[name]
167
- end
168
-
169
- def core
170
- @plugins['core']
171
- end
172
-
173
- def hook(name, plugin_name)
174
- @hooks[name] << plugin_name
175
- end
176
-
177
- def call_hook(name, *payload)
178
- @hooks[name].each do |plugin_name, block|
179
- @plugins[plugin_name].call_hook(name, payload)
180
- end
181
- end
182
-
183
- def before_execute(request)
184
- #puts ">> #{request.command_name} #{request.param_values.keys}"
185
- call_hook :before_execute, request
186
- end
187
-
188
- def after_execute(request, response)
189
- #puts "<< #{request.command_name} #{response.result}"
190
- call_hook :after_execute, request, response
191
- end
192
-
193
- def execute(command_name, param_values, extra = {})
194
- request = Request.new(command_name, param_values, extra)
195
- before_execute(request)
196
-
197
- (result, context) = execute_command(command_name, param_values, extra)
198
-
199
- response = Response.new(result, context)
200
- after_execute(request, response)
201
-
202
- result
203
- end
204
-
205
- def execute_command(command_name, param_values, extra = {})
206
- $logger.debug "+++ #{command_name} +++"
207
- command = @commands[command_name]
208
-
209
- command.execute(param_values, extra)
210
- end
211
-
212
- def shutdown()
213
- $logger.debug "shutting down..."
214
- end
215
-
9
+ def self.setup(options = {})
10
+ ::Vop::Vop.new(options)
216
11
  end
217
12
 
218
- class Request
219
-
220
- attr_reader :command_name, :param_values
221
-
222
- def initialize(command_name, param_values, extra = {})
223
- @command_name = command_name
224
- @param_values = param_values
225
- # fuck extra
13
+ def self.boot(options = {})
14
+ if ENV["VOP_DEV_MODE"]
15
+ sibling_lib_dir = Pathname.new(File.join(File.dirname(__FILE__), "..", "lib")).realpath
16
+ if File.exists? sibling_lib_dir
17
+ #puts "adding local lib path #{sibling_lib_dir}"
18
+ $: << sibling_lib_dir
19
+ end
226
20
  end
227
21
 
22
+ ::Vop.setup(options)
228
23
  end
229
24
 
230
- class Response
231
-
232
- attr_reader :result
233
-
234
- def initialize(result, context)
235
- @result = result
236
- @context = context
237
- end
238
-
25
+ def vop_setup(options = {})
26
+ ::Vop.boot(options)
239
27
  end
240
28
 
241
-
242
29
  end
data/vop.gemspec CHANGED
@@ -1,31 +1,34 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ # encoding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
3
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'vop/version'
4
+ require "vop/version"
5
5
 
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "vop"
8
8
  spec.version = Vop::VERSION
9
9
  spec.authors = ["Philipp T."]
10
- spec.email = ["philipp@virtualop.org"]
10
+ spec.email = ["philipp@hitchhackers.net"]
11
11
 
12
- spec.summary = %q{The virtualop is a tool for automating things.}
13
- spec.description = %q{Automated processes fail more consistently, that's why I wrote a tool to make scripts that will do things that we won't have to do ourselves then. (see xkcd #1629)}
14
- spec.homepage = "http://www.virtualop.org"
12
+ spec.summary = %q{The vop is a scripting framework.}
13
+ spec.description = %q{Automation framework with a plugin/command architecture, entities, contributions, filters and asynchronous workers. Shell included, web interface in a separate project.}
14
+ spec.homepage = "http://virtualop.org"
15
15
 
16
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
17
19
  spec.bindir = "exe"
18
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
21
  spec.require_paths = ["lib"]
20
22
 
21
- spec.add_dependency "terminal-table"
22
23
  spec.add_dependency "net-ssh"
23
24
  spec.add_dependency "net-scp"
24
- spec.add_dependency "docopt"
25
- spec.add_dependency "activesupport"
26
- spec.add_dependency "pry"
25
+ spec.add_dependency "redis"
26
+ spec.add_dependency "sidekiq"
27
+ spec.add_dependency "terminal-table"
28
+ spec.add_dependency "xml-simple"
27
29
 
28
- spec.add_development_dependency "bundler", "~> 1.10"
29
- spec.add_development_dependency "rake"
30
- spec.add_development_dependency "rspec"
30
+ spec.add_development_dependency "bundler", "~> 1.15"
31
+ spec.add_development_dependency "rake", "~> 10.0"
32
+ spec.add_development_dependency "rspec", "~> 3.0"
33
+ spec.add_development_dependency "simplecov"
31
34
  end