ki-repo 0.1.0 → 0.1.1

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.
@@ -0,0 +1,258 @@
1
+ # encoding: UTF-8
2
+
3
+ # Copyright 2012 Mikko Apo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ # Attaches configurable behaviour to accessor methods
18
+ #
19
+ # class Foo
20
+ # attr_chain :name, :require
21
+ # attr_chain :email, -> {""}
22
+ # attr_chain :birth_day, :immutable, :valid => lambda { |i| (1870..Time.now.year+1).include?(i) }, :require => true
23
+ # attr_chain :children, :convert => lambda {|s| s.to_i}
24
+ # end
25
+ #
26
+ # Sets up public methods variable_name and variable_name= which both can be used to access the fields. Giving any parameters
27
+ # for the method makes it a "set" operation and giving no parameters makes it a "get" operation. "Set" stores the value
28
+ # and returns self, so set calls can be chained. "Get" returns the stored value.
29
+ #
30
+ # foo.email("test@email.com").name("test")
31
+ # foo.email => "test@email.com"
32
+ # foo.name => "test"
33
+ #
34
+ # Parameters can be given in short and long format. Short format works by identifying parameter types,
35
+ # long format works by given the name and value as hash parameters:
36
+ # * <tt>:require=>"You need to define xxx first"</tt>, <tt>:require=>true</tt>, short: <tt>:require</tt> - an exception is thrown if target field is not defined
37
+ # * <tt>:default=> -> {true}</tt>, short: <tt>-> {Array.new}</tt> - if target field has not been defined, executes proc and stores value. proc is executed using object.instance_exec: object's fields & methds are available
38
+ # * <tt>:immutable=>true</tt>, short: <tt>:immutable</tt> - an exception is thrown if target field is defined a second time
39
+ # * <tt>:valid=>[1,2,3,"a", lambda {|s| s.include?("b")}]</tt>, <tt>:valid => lambda {|s| s.include?("b")}</tt>, short: <tt>[1,2,3,"a"]</tt> - List of valid values. If any matches, sets value. If none matches, raises exception. Long form wraps single arguments to a list.
40
+ # * <tt>:convert=> ->(s) { s+1 }</tt> - Converts input value using the defined proc
41
+ # * <tt>:accessor=>InstanceVariableAccessor.new</tt> - Makes it possible to set values in other source, for example a hash. By default uses InstanceVariableAccessor
42
+ #
43
+ # Advantages for using attr_chain
44
+ # * attr_chain has a compact syntax for many important programming concepts -> less manually written boilerplate code is needed
45
+ # * :default makes it easy to isolate functionality to a default value while still making it easy to override the default behaviour
46
+ # * :default adds easy lazy evalution and memoization to the attribute, default value is evaluated only if needed
47
+ # * Testing becomes easier when objects have more exposed fields
48
+ # * :require converts tricky nil exceptions in to useful errors. Instead of the "undefined method `bar' for nil:NilClass" you get a good error message that states which field was not defined
49
+ # foo.name.bar # if name has not been defined, raises "'name' has not been set" exception
50
+ # * :immutable, :valid and :convert make complex validations and converts easy
51
+ #
52
+ # Warnings about attr_chain
53
+ # * Performance has not been measured and attr_chain is probably not efficient. If there are tight inner loops, it's better to cache the value and store it afterwards
54
+ # * There has not been tests for memory leaks. It's plain ruby so GC should take care of everything
55
+ # * Excessive attr_chain usage makes classes a mess. Try to keep your classes short and attr_chain count below 10.
56
+ # @see InstanceVariableAccessor
57
+ # @see Object.attr_chain
58
+ # @see Module#attr_chain
59
+ class AttrChain
60
+ # Parses parameters with parse_short_syntax and set_parameters and configures class methods
61
+ # * each attr_chain definition uses one instance of AttrChain which holds the configuration for the definition
62
+ # * Object::define_method is used to add two methods to target class and when called both of these methods call attr_chain with their parameters
63
+ # @see Object.attr_chain
64
+ # @see Module#attr_chain
65
+ def initialize(clazz, variable_name, attr_configs)
66
+ @variable_name = variable_name
67
+ @accessor = InstanceVariableAccess
68
+ set_parameters(variable_name, parse_short_syntax(variable_name, attr_configs))
69
+ me = self
70
+ attr_call = lambda { |*args| me.attr_chain(self, args) }
71
+ [variable_name, "#{variable_name}="].each do |method_name|
72
+ if clazz.method_defined?(method_name)
73
+ clazz.send(:undef_method, method_name)
74
+ end
75
+ clazz.send(:define_method, method_name, attr_call)
76
+ end
77
+ end
78
+
79
+ # Converts short syntax entries in attr_configs to long syntax
80
+ # * warns about not supported values and already defined values
81
+ def parse_short_syntax(variable_name, attr_configs)
82
+ params = {}
83
+ attr_configs.each do |attr_config|
84
+ key_values = if [:require, :immutable].include?(attr_config)
85
+ [[attr_config, true]]
86
+ elsif attr_config.kind_of?(Proc)
87
+ [[:default, attr_config]]
88
+ elsif attr_config.kind_of?(Array)
89
+ [[:valid, attr_config]]
90
+ elsif attr_config.kind_of?(Hash)
91
+ all = []
92
+ attr_config.each_pair do |pair|
93
+ all << pair
94
+ end
95
+ all
96
+ else
97
+ raise "attr_chain :#{variable_name} unsupported parameter: '#{attr_config.inspect}'"
98
+ end
99
+ key_values.each do |key, value|
100
+ if params.include?(key)
101
+ raise "attr_chain :#{variable_name}, :#{key} was already defined to '#{params[key]}' (new value: '#{value}')"
102
+ end
103
+ params[key]=value
104
+ end
105
+ end
106
+ params
107
+ end
108
+
109
+ # Parses long syntax values and sets configuration for this field
110
+ def set_parameters(variable_name, params)
111
+ params.each_pair do |key, value|
112
+ case key
113
+ when :require
114
+ @require = value
115
+ when :default
116
+ if !value.kind_of?(Proc)
117
+ raise "attr_chain :#{variable_name}, :default needs to be a Proc, not '#{value.inspect}'"
118
+ end
119
+ @default = value
120
+ when :immutable
121
+ @immutable = value
122
+ when :valid
123
+ if !value.kind_of?(Array)
124
+ value = [value]
125
+ end
126
+ value.each do |valid|
127
+ if valid.kind_of?(Proc)
128
+ @valid_procs ||= []
129
+ @valid_procs << valid
130
+ else
131
+ @valid_items ||= {}
132
+ @valid_items[valid]=valid
133
+ end
134
+ end
135
+ when :convert
136
+ if !value.kind_of?(Proc)
137
+ raise "attr_chain :#{variable_name}, :convert needs to be a Proc, not '#{value.inspect}'"
138
+ end
139
+ @convert = value
140
+ when :accessor
141
+ @accessor = value
142
+ else
143
+ raise "attr_chain :#{variable_name} unsupported parameter: '#{key.inspect}'"
144
+ end
145
+ end
146
+ end
147
+
148
+ # Handles incoming methods for "get" and "set"
149
+ # * called by methods defined to class
150
+ # * configuration is stored as instance variables, the class knows which variable is being handled
151
+ # * method call parameters come as list of parameters
152
+ def attr_chain(object, args)
153
+ if args.empty?
154
+ if !@accessor.defined?(object, @variable_name)
155
+ if defined? @default
156
+ @accessor.set(object, @variable_name, object.instance_exec(&@default))
157
+ elsif defined? @require
158
+ if @require.kind_of?(String)
159
+ raise "'#{@variable_name}' has not been set: #{@require}"
160
+ else
161
+ raise "'#{@variable_name}' has not been set"
162
+ end
163
+ end
164
+ end
165
+ @accessor.get(object, @variable_name)
166
+ else
167
+ if defined?(@immutable) && @accessor.defined?(object, @variable_name)
168
+ raise "'#{@variable_name}' has been set once already"
169
+ end
170
+ value_to_set = if args.size == 1
171
+ args.first
172
+ else
173
+ args
174
+ end
175
+ if defined? @convert
176
+ value_to_set = object.instance_exec(value_to_set, &@convert)
177
+ end
178
+ if defined?(@valid_items) || defined?(@valid_procs)
179
+ is_valid = false
180
+ if defined?(@valid_items) && @valid_items.include?(value_to_set)
181
+ is_valid = true
182
+ end
183
+ if is_valid == false && defined?(@valid_procs)
184
+ @valid_procs.each do |valid_proc|
185
+ if is_valid=object.instance_exec(value_to_set, &valid_proc)
186
+ break
187
+ end
188
+ end
189
+ end
190
+ if is_valid == false
191
+ raise "invalid value for '#{@variable_name}'"
192
+ end
193
+ end
194
+ @accessor.set(object, @variable_name, value_to_set)
195
+ object
196
+ end
197
+ end
198
+
199
+ # Wrapper for Object::instance_variable_get, Object::instance_variable_set and Object::instance_variable_defined?
200
+ class InstanceVariableAccessor
201
+ def edit_name(variable_name)
202
+ "@#{variable_name}".to_sym
203
+ end
204
+
205
+ def get(object, name)
206
+ n = edit_name(name)
207
+ if object.instance_variable_defined?(n)
208
+ object.instance_variable_get(n)
209
+ else
210
+ nil
211
+ end
212
+ end
213
+
214
+ def set(object, name, value)
215
+ object.instance_variable_set(edit_name(name), value)
216
+ end
217
+
218
+ def defined?(object, name)
219
+ object.instance_variable_defined?(edit_name(name))
220
+ end
221
+ end
222
+
223
+ InstanceVariableAccess = InstanceVariableAccessor.new
224
+
225
+ class HashAccessor
226
+ def get(object, name)
227
+ object[name.to_s]
228
+ end
229
+
230
+ def set(object, name, value)
231
+ object[name.to_s] = value
232
+ end
233
+
234
+ def defined?(object, name)
235
+ object.include?(name.to_s)
236
+ end
237
+ end
238
+
239
+ HashAccess = HashAccessor.new
240
+ end
241
+
242
+ class Object
243
+ # Configurable accessor methods
244
+ # @see AttrChain
245
+ # @return [void]
246
+ def self.attr_chain(variable_name, *attr_configs)
247
+ AttrChain.new(self, variable_name, attr_configs)
248
+ end
249
+ end
250
+
251
+ class Module
252
+ # When a module defines an attr_chain, the attr_chain methods are available to all classes that are extended with the module
253
+ # @see AttrChain
254
+ # @return [void]
255
+ def attr_chain(variable_name, *attr_configs)
256
+ AttrChain.new(self, variable_name, attr_configs)
257
+ end
258
+ end
@@ -0,0 +1,118 @@
1
+ # encoding: UTF-8
2
+
3
+ # Copyright 2012 Mikko Apo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require 'thread'
18
+
19
+ # ExceptionCatcher makes it easy to execute multiple operations even though any might fail with exception
20
+ # * executed tasks can be named to make it easier to identify which tasks failed
21
+ # * collects result and exceptions for each executed task
22
+ # * raises a {MultipleExceptions} exception if there were more than one exception
23
+ #
24
+ # catcher = ExceptionCatcher.new
25
+ # catcher.catch(1){raise "1"}
26
+ # catcher.catch(2){raise "2"}
27
+ # catcher.catch(3){"ok!"}
28
+ # catcher.exception(1) # -> exception object raised by the task 1
29
+ # catcher.exception(2) # -> exception object raise by the task 2
30
+ # catcher.result(3) # -> "ok!"
31
+ # catcher.check # raises a MultipleExceptions exception
32
+ #
33
+ class ExceptionCatcher
34
+ attr_reader :tasks, :results, :exceptions, :mutex
35
+
36
+ def initialize
37
+ @mutex = Mutex.new
38
+ @results = {}
39
+ @exceptions = {}
40
+ @tasks = []
41
+ end
42
+
43
+ # Catches exceptions thrown by block
44
+ # @param [Object, nil] task if task is defined, results can be checked per task
45
+ # @param [Proc] block, block which is executed
46
+ # @return [Object, nil] returns block's result or nil if block raised an exception
47
+ def catch(task=nil, &block)
48
+ if task.nil?
49
+ task = block
50
+ end
51
+ @mutex.synchronize do
52
+ @tasks << task
53
+ end
54
+ begin
55
+ result = block.call
56
+ @mutex.synchronize do
57
+ @results[task]=result
58
+ end
59
+ result
60
+ rescue Exception => e
61
+ @mutex.synchronize do
62
+ @exceptions[task]=e
63
+ end
64
+ nil
65
+ end
66
+ end
67
+
68
+ # @param [Object] task identifies the executed task
69
+ # @return [Object,nil] result for the named block or nil if the block ended in exception
70
+ def result(task)
71
+ @mutex.synchronize do
72
+ @results[task]
73
+ end
74
+ end
75
+
76
+ # @param [Object] task identifies the executed task
77
+ # @return [Object,nil] block's exception or nil if the block did not raise an exception
78
+ def exception(task)
79
+ @mutex.synchronize do
80
+ @exceptions[task]
81
+ end
82
+ end
83
+
84
+ # @return [bool] exceptions? returns true if there has been exceptions
85
+ def exceptions?
86
+ @exceptions.size > 0
87
+ end
88
+
89
+ # Checks if there has been exceptions and raises the original exception if there has been one exception and {MultipleExceptions} if there has been many exceptions
90
+ def check
91
+ @mutex.synchronize do
92
+ if @exceptions.size == 1
93
+ e = @exceptions.values.first
94
+ raise e.class, e.message, e.backtrace
95
+ elsif @exceptions.size > 1
96
+ raise MultipleExceptions.new("Caught #{@exceptions.size} exceptions!").catcher(self)
97
+ end
98
+ end
99
+ end
100
+
101
+ # {ExceptionCatcher} raises MultipleExceptions if it has caught multiple exceptions.
102
+ # MultipleExceptions makes the {ExceptionCatcher} and its results available
103
+ class MultipleExceptions < RuntimeError
104
+ include Enumerable
105
+ # Reference to original {#ExceptionCatcher}
106
+ attr_chain :catcher, :require
107
+ # Map of exceptions
108
+ attr_chain :exceptions, -> { catcher.exceptions }
109
+ # Map of tasks that have been scheduled
110
+ attr_chain :tasks, -> { catcher.tasks }
111
+
112
+ def each(&block)
113
+ exceptions.values.each(&block)
114
+ end
115
+ end
116
+ end
117
+
118
+
data/lib/util/hash.rb ADDED
@@ -0,0 +1,46 @@
1
+ # encoding: UTF-8
2
+
3
+ # Copyright 2012 Mikko Apo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require 'digest/sha1'
18
+ require 'digest/sha2'
19
+ require 'digest/md5'
20
+
21
+ module Ki
22
+
23
+ # SHA1, uses standard Ruby library
24
+ class SHA1
25
+ # SHA1, uses standard Ruby library
26
+ def SHA1.digest
27
+ Digest::SHA1.new
28
+ end
29
+ end
30
+
31
+ # SHA2, uses standard Ruby library
32
+ class SHA2
33
+ # SHA2, uses standard Ruby library
34
+ def SHA2.digest
35
+ Digest::SHA2.new
36
+ end
37
+ end
38
+
39
+ # MD5, uses standard Ruby library
40
+ class MD5
41
+ # MD5, uses standard Ruby library
42
+ def MD5.digest
43
+ Digest::MD5.new
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ # encoding: UTF-8
2
+
3
+ # Copyright 2012 Mikko Apo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module Ki
18
+ # Caching Hash, resolves values at request time
19
+ class HashCache < Hash
20
+ # If key has not been defined, uses block to resolve the value. Value is stored and returned
21
+ # @param key Key
22
+ # @param [Proc] block Block which is evaluated if the key does not have value yet. Block's value is stored to hash
23
+ # @return Existing value or one resolved with the block
24
+ def cache(key, &block)
25
+ if !include?(key)
26
+ store(key, block.call)
27
+ end
28
+ self[key]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,137 @@
1
+ # encoding: UTF-8
2
+
3
+ # Copyright 2012 Mikko Apo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module Ki
18
+ module KiEnumerable
19
+ def size!(*args)
20
+ args.each do |valid_size|
21
+ if valid_size.kind_of?(Range)
22
+ if valid_size.include?(size)
23
+ return self
24
+ end
25
+ elsif valid_size.respond_to?(:to_i)
26
+ if Integer(valid_size) == size
27
+ return self
28
+ end
29
+ else
30
+ raise "'#{valid_size.inspect}' not supported, needs to be either Range or have .to_i method"
31
+ end
32
+ end
33
+ raise "size #{size} does not match '#{args.map { |s| s.to_s }.join("', '")}'"
34
+ end
35
+
36
+ def any_matches?(value)
37
+ each do |pattern|
38
+ if value.match(pattern)
39
+ return pattern
40
+ end
41
+ end
42
+ false
43
+ end
44
+
45
+ def find_first(count=1, &block)
46
+ ret = []
47
+ each do |item|
48
+ if block.nil? || block.call(item)
49
+ ret << item
50
+ if ret.size == count
51
+ break
52
+ end
53
+ end
54
+ end
55
+ if count==1
56
+ ret.at(0)
57
+ else
58
+ ret
59
+ end
60
+ end
61
+
62
+ def to_h(separator=nil, &block)
63
+ ret = {}
64
+ each do |item|
65
+ if separator
66
+ key, *values = item.split(separator)
67
+ if values.size > 0 || item.include?(separator)
68
+ ret[key]=values.join(separator)
69
+ else
70
+ ret[key]=true
71
+ end
72
+ elsif block
73
+ key, value = block.call(item)
74
+ ret[key]=value
75
+ end
76
+ end
77
+ ret
78
+ end
79
+
80
+ end
81
+ end
82
+
83
+ class Array
84
+ include Ki::KiEnumerable
85
+
86
+ def Array.wrap(maybe_arr)
87
+ if maybe_arr.kind_of?(Array)
88
+ maybe_arr
89
+ else
90
+ [maybe_arr]
91
+ end
92
+ end
93
+ end
94
+
95
+ module Enumerable
96
+ include Ki::KiEnumerable
97
+ end
98
+
99
+ require 'fileutils'
100
+ class File
101
+ def File.safe_write(dest, txt=nil, &block)
102
+ tmp = dest + "-" + rand(9999).to_s
103
+ begin
104
+ File.open(tmp, "w") do |file|
105
+ if block
106
+ block.call(file)
107
+ elsif txt
108
+ file.write(txt)
109
+ end
110
+ end
111
+ FileUtils.mv(tmp, dest)
112
+ rescue Exception => e
113
+ FileUtils.remove_entry_secure(tmp)
114
+ raise
115
+ end
116
+ end
117
+ end
118
+
119
+ class Hash
120
+ original_get = self.instance_method(:[])
121
+
122
+ define_method(:[]) do |key, default=nil|
123
+ value = original_get.bind(self).call(key)
124
+ if value || include?(key)
125
+ value
126
+ else
127
+ default
128
+ end
129
+ end
130
+
131
+ def require(key)
132
+ if !include?(key)
133
+ raise "'#{key}' is not defined!"
134
+ end
135
+ self[key]
136
+ end
137
+ end
@@ -0,0 +1,88 @@
1
+ # encoding: UTF-8
2
+
3
+ # Copyright 2012 Mikko Apo
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require 'monitor'
18
+
19
+ module Ki
20
+ class ServiceRegistry < Hash
21
+ attr_reader :by_parent
22
+
23
+ def initialize
24
+ @monitor = Monitor.new
25
+ @by_parent = {}
26
+ end
27
+
28
+ def register(*args)
29
+ @monitor.synchronize do
30
+ case args.size
31
+ when 1
32
+ args.first.each_pair do |url, clazz|
33
+ register(url, clazz)
34
+ end
35
+ when 2
36
+ url, clazz = args
37
+ self[url]=clazz
38
+ (@by_parent[File.dirname(url)]||=Array.new) << args
39
+ else
40
+ raise "Not supported '#{args.inspect}'"
41
+ end
42
+ end
43
+ self
44
+ end
45
+
46
+ def find(url, value=nil)
47
+ @monitor.synchronize do
48
+ if include?(url)
49
+ self[url]
50
+ elsif @by_parent.include?(url)
51
+ services = @by_parent[url]
52
+ if services
53
+ if value
54
+ services = services.select { |id, service| service.supports?(value) }
55
+ end
56
+ services = ServiceList.new.concat(services)
57
+ end
58
+ services
59
+ end
60
+ end
61
+ end
62
+
63
+ def find!(url, value=nil)
64
+ found = find(url, value)
65
+ if found.nil?
66
+ raise "Could not resolve '#{url}'"
67
+ end
68
+ found
69
+ end
70
+
71
+ def clear
72
+ @monitor.synchronize do
73
+ @by_parent.clear
74
+ super
75
+ end
76
+ end
77
+
78
+ class ServiceList < Array
79
+ def services
80
+ map { |url, service| service }
81
+ end
82
+
83
+ def service_names
84
+ map { |url, service| File.basename(url) }
85
+ end
86
+ end
87
+ end
88
+ end