proby 2.0.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.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ *.swp
2
+ doc
3
+ .yardoc
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :rubygems
2
+
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,36 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ proby (2.0.0)
5
+ chronic (~> 0.6.7)
6
+ httparty (~> 0.8.1)
7
+ multi_json (~> 1.2.0)
8
+
9
+ GEM
10
+ remote: http://rubygems.org/
11
+ specs:
12
+ bluecloth (2.1.0)
13
+ chronic (0.6.7)
14
+ fakeweb (1.3.0)
15
+ httparty (0.8.3)
16
+ multi_json (~> 1.0)
17
+ multi_xml
18
+ json (1.6.6)
19
+ multi_json (1.2.0)
20
+ multi_xml (0.4.4)
21
+ rake (0.9.2.2)
22
+ shoulda (2.11.3)
23
+ yard (0.6.8)
24
+
25
+ PLATFORMS
26
+ ruby
27
+
28
+ DEPENDENCIES
29
+ bluecloth (~> 2.1.0)
30
+ bundler (>= 1.0.0)
31
+ fakeweb (~> 1.3.0)
32
+ json (~> 1.6.6)
33
+ proby!
34
+ rake (~> 0.9.0)
35
+ shoulda (~> 2.11.3)
36
+ yard (~> 0.6.4)
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Proby
2
+ A simple library for working with the Proby task monitoring application.
3
+
4
+
5
+ Installation
6
+ ------------
7
+
8
+ ### RubyGems ###
9
+ Proby can be installed using RubyGems
10
+
11
+ gem install proby
12
+
13
+ Inside your script, be sure to
14
+
15
+ require "rubygems"
16
+ require "proby"
17
+
18
+ ### Bundler ###
19
+ If you're using Bundler, add the following to your Gemfile
20
+
21
+ gem "proby"
22
+
23
+ and then run
24
+
25
+ bundle install
26
+
27
+
28
+ Setup
29
+ -----
30
+ Before notifications can be sent, you must tell Proby your API key. This only needs to be done once,
31
+ and should ideally be done inside your apps initialization code.
32
+
33
+ Proby.api_key = "b4fe1200c105012efde3482a1411a947"
34
+
35
+ In addition, you can optionally give Proby a logger to use.
36
+
37
+ Proby.logger = Rails.logger
38
+
39
+
40
+ Sending Notifications
41
+ ---------------------
42
+ To send a start notification
43
+
44
+ Proby.send_start_notification(task_api_id)
45
+
46
+ To send a finish notification
47
+
48
+ Proby.send_finish_notification(task_api_id)
49
+
50
+ Specifying the `task_api_id` when calling the notification methods is optional. If it is not provided,
51
+ Proby will use the value of the `PROBY_TASK_ID` environment variable. If no task id is specified
52
+ in the method call, and no value is set in the `PROBY_TASK_ID` environment variable, then no notification
53
+ will be sent.
54
+
55
+
56
+ The Resque Plugin
57
+ -----------------
58
+ The Resque plugin will automatically send start and finish notifications to Proby when your job
59
+ starts and finishes. Simply `extend Proby::ResquePlugin` in your Resque job. The task id
60
+ can either be pulled from the `PROBY_TASK_ID` environment variable, or specified in the job itself
61
+ by setting the `@proby_id` attribute to the task id.
62
+
63
+ class SomeJob
64
+ extend Proby::ResquePlugin
65
+ @proby_id = 'abc123' # Or simply let it use the value in the PROBY_TASK_ID environment variable
66
+
67
+ self.perform
68
+ do_stuff
69
+ end
70
+ end
71
+
72
+
73
+ Managing Tasks
74
+ --------------
75
+ The Proby::ProbyTask class can be used to create, read, update, delete, pause, and unpause your
76
+ tasks on Proby.
77
+
78
+ my_tasks = Proby::ProbyTask.find(:all)
79
+ a_specific_task = Proby::ProbyTask.find("the_proby_task_id")
80
+
81
+ task = Proby::ProbyTask.create(:name => 'Task name', :crontab => '* * * * *')
82
+
83
+ task.name = "New name"
84
+ task.save
85
+
86
+ task.pause
87
+ task.unpause
88
+
89
+ task.delete
90
+
91
+
92
+ API Doc
93
+ -------
94
+ [http://rdoc.info/github/signal/proby-ruby/master/frames](http://rdoc.info/github/signal/proby-ruby/master/frames)
95
+
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ begin
7
+ Bundler.setup(:default, :development)
8
+ rescue Bundler::BundlerError => e
9
+ $stderr.puts e.message
10
+ $stderr.puts "Run `bundle install` to install missing gems"
11
+ exit e.status_code
12
+ end
13
+
14
+ require 'rake'
15
+ require 'rake/testtask'
16
+ require 'yard'
17
+
18
+ task :default => :test
19
+
20
+ Rake::TestTask.new(:test) do |test|
21
+ test.libs << 'lib' << 'test'
22
+ test.pattern = ENV['TEST'] || "test/**/*_test.rb"
23
+ test.verbose = true
24
+ end
25
+
26
+ YARD::Rake::YardocTask.new do |t|
27
+ t.files = ['lib/**/*.rb']
28
+ end
29
+
30
+ desc 'Delete yard, and other generated files'
31
+ task :clobber => [:clobber_yard]
32
+
33
+ desc 'Delete yard generated files'
34
+ task :clobber_yard do
35
+ puts 'rm -rf doc .yardoc'
36
+ FileUtils.rm_rf ['doc', '.yardoc']
37
+ end
38
+
@@ -0,0 +1,13 @@
1
+ module Proby
2
+ # Exception raised when a request to Proby fails
3
+ class ApiException < StandardError; end
4
+
5
+ # Exception raised when the api key is not properly set
6
+ class InvalidApiKeyException < StandardError; end
7
+
8
+ # Authentication to Proby failed. Make sure your API key is correct.
9
+ class AuthFailedException < StandardError; end
10
+
11
+ # An invalid parameter was passed to the given method
12
+ class InvalidParameterException < StandardError; end
13
+ end
@@ -0,0 +1,28 @@
1
+ module Proby
2
+ class Notifier < ProbyHttpApi
3
+
4
+ def self.send_notification(type, proby_task_id, options={})
5
+ if Proby.api_key.nil?
6
+ Proby.logger.warn "Proby: No notification sent because API key is not set"
7
+ return nil
8
+ end
9
+
10
+ proby_task_id = ENV['PROBY_TASK_ID'] if blank?(proby_task_id)
11
+ if blank?(proby_task_id)
12
+ Proby.logger.warn "Proby: No notification sent because task ID was not specified"
13
+ return nil
14
+ end
15
+
16
+ response = post("/api/v1/tasks/#{proby_task_id}/#{type}.json",
17
+ :body => MultiJson.encode(options),
18
+ :format => :json,
19
+ :headers => default_headers)
20
+ response.code
21
+ rescue Exception => e
22
+ Proby.logger.error "Proby: Proby notification failed: #{e.message}"
23
+ Proby.logger.error e.backtrace
24
+ end
25
+
26
+ end
27
+ end
28
+
@@ -0,0 +1,28 @@
1
+ module Proby
2
+ class ProbyHttpApi
3
+ include HTTParty
4
+ base_uri "https://proby.signalhq.com"
5
+ default_timeout 5
6
+
7
+ protected
8
+
9
+ def self.handle_api_failure(response)
10
+ if response.code == 401
11
+ raise AuthFailedException.new("Authentication to Proby failed. Make sure your API key is correct.")
12
+ else
13
+ message = "API request failed with a response code of #{response.code}. Respone body: #{response.body}"
14
+ Proby.logger.error message
15
+ raise ApiException.new(message)
16
+ end
17
+ end
18
+
19
+ def self.default_headers
20
+ { 'api_key' => Proby.api_key, 'Content-Type' => 'application/json' }
21
+ end
22
+
23
+ def self.blank?(s)
24
+ s.nil? || s.strip.empty?
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,278 @@
1
+ module Proby
2
+
3
+ # Represents the status of a Proby task
4
+ class ProbyTaskStatus
5
+ # The description of the task (OK, ERROR, PAUSED)
6
+ attr_reader :description
7
+
8
+ # Any details (why the task is in the ERROR state)
9
+ attr_reader :details
10
+
11
+ def initialize(attributes={})
12
+ @description = attributes['description']
13
+ @details = attributes['details']
14
+ end
15
+ end
16
+
17
+ # Represents a task in Proby
18
+ class ProbyTask < ProbyHttpApi
19
+ # The name of the task
20
+ attr_accessor :name
21
+
22
+ # The schedule for the task, specified in crontab format
23
+ attr_accessor :crontab
24
+
25
+ # The time zone of the machine executing the task
26
+ attr_accessor :time_zone
27
+
28
+ # The name of the machine that is responsible for running this task
29
+ attr_accessor :machine
30
+
31
+ # Should finish alarms be sent when the task runs longer than expected?
32
+ attr_accessor :finish_alarms_enabled
33
+
34
+ # The maximum amount of time the task is allowed to run before Proby sends
35
+ # a finish alarm. If not specified, Proby will determine when an alarm should
36
+ # be sent based on past run times
37
+ attr_accessor :maximum_run_time
38
+
39
+ # The number of minutes to wait for a task to send its start notification after it
40
+ # should have started before sending an alarm
41
+ attr_accessor :start_notification_grace_period
42
+
43
+ # The number of consecutive tasks that must fail before an alarm is sent
44
+ attr_accessor :consecutive_alarmed_tasks_required_to_trigger_alarm
45
+
46
+ # The API Task ID of the task
47
+ attr_reader :api_id
48
+
49
+ # Is the task currently paused?
50
+ attr_reader :paused
51
+
52
+ # The number of consecutive times this task has triggered an alarm
53
+ attr_reader :consecutive_alarmed_tasks
54
+
55
+ # The date and time this task was created
56
+ attr_reader :created_at
57
+
58
+ # The date and time this task was updated
59
+ attr_reader :updated_at
60
+
61
+ # The current status of the task, represented as a ProbyTaskStatus
62
+ attr_reader :status
63
+
64
+ # <b>Should not be called directly</b>
65
+ def initialize(attributes={})
66
+ @name = attributes['name']
67
+ @api_id = attributes['api_id']
68
+ @crontab = attributes['crontab']
69
+ @paused = attributes['paused']
70
+ @time_zone = attributes['time_zone']
71
+ @machine = attributes['machine']
72
+ @finish_alarms_enabled = attributes['finish_alarms_enabled']
73
+ @maximum_run_time = attributes['maximum_run_time']
74
+ @start_notification_grace_period = attributes['start_notification_grace_period']
75
+ @consecutive_alarmed_tasks = attributes['consecutive_alarmed_tasks']
76
+ @consecutive_alarmed_tasks_required_to_trigger_alarm = attributes['consecutive_alarmed_tasks_required_to_trigger_alarm']
77
+ @created_at = Chronic.parse attributes['created_at']
78
+ @updated_at = Chronic.parse attributes['updated_at']
79
+ @status = ProbyTaskStatus.new(attributes['status']) if attributes['status']
80
+ end
81
+
82
+ # Get a single, or all tasks from Proby.
83
+ #
84
+ # @param [Object] param :all if you are fetching all tasks, or the api_id of the Proby task you would like to fetch.
85
+ #
86
+ # @return If requesting all tasks, an [Array<ProbyTask>] will be returned. If an api_id was provided, the
87
+ # ProbyTask with that api_id will be returned if it exists, or nil if it could not be found.
88
+ #
89
+ # @example
90
+ # all_of_my_tasks = ProbyTask.find(:all)
91
+ # my_task = ProbyTask.find('my_proby_task_api_id')
92
+ def self.find(param)
93
+ ensure_api_key_set
94
+ param == :all ? list : fetch(param)
95
+ end
96
+
97
+ # Create a new Proby task.
98
+ #
99
+ # @param [Hash] attributes The attributes for your task.
100
+ # @option attributes [String] :name A name for your task.
101
+ # @option attributes [String] :crontab The schedule of the task, specified in cron format.
102
+ # @option attributes [String] :time_zone <b>(Optional)</b> The time zone of the machine executing the task.
103
+ # @option attributes [String] :machine <b>(Optional)</b> The name of the machine that is responsible for running this task.
104
+ # Will default to the default time zone configured in Proby if not specified.
105
+ # @option attributes [Boolean] :finish_alarms_enabled <b>(Optional)</b> true if you would like to receive finish alarms for
106
+ # this task, false otherwise (default: true).
107
+ # @option attributes [Fixnum] :maximum_run_time <b>(Optional)</b> The maximum amount of time the task is allowed to run before
108
+ # Proby sends a finish alarm. If not specified, Proby will determine when an alarm should be
109
+ # sent based on past run times.
110
+ # @option attributes [Fixnum] :start_notification_grace_period <b>(Optional)</b> The number of minutes to wait for a task to
111
+ # send its start notification after it should have started before sending an alarm.
112
+ # @option attributes [Fixnum] :consecutive_alarmed_tasks_required_to_trigger_alarm <b>(Optional)</b> The number of consecutive
113
+ # tasks that must fail before an alarm is sent.
114
+ #
115
+ # @return [ProbyTask] The task that was created.
116
+ #
117
+ # @example
118
+ # proby_task = ProbyTask.create(:name => "My new task", :crontab => "* * * * *")
119
+ def self.create(attributes={})
120
+ ensure_api_key_set
121
+ raise InvalidParameterException.new("attributes are required") if attributes.nil? || attributes.empty?
122
+ raise InvalidParameterException.new("name is required") unless !blank?(attributes[:name]) || !blank?(attributes['name'])
123
+ raise InvalidParameterException.new("crontab is required") unless !blank?(attributes[:crontab]) || !blank?(attributes['crontab'])
124
+
125
+ Proby.logger.info "Creating task with attributes: #{attributes.inspect}"
126
+ response = post("/api/v1/tasks.json",
127
+ :format => :json,
128
+ :body => MultiJson.encode(:task => attributes),
129
+ :headers => default_headers)
130
+
131
+ if response.code == 201
132
+ new(response.parsed_response['task'])
133
+ else
134
+ handle_api_failure(response)
135
+ end
136
+ end
137
+
138
+ # Saves the task in Proby, updating all attributes to the values stored in the object. Only the attributes specified in
139
+ # the ProbyTask.create documentation can be updated.
140
+ #
141
+ # @example
142
+ # proby_task = ProbyTask.get('my_proby_task_api_id')
143
+ # proby_task.name = "Some other name"
144
+ # proby_task.crontab = "1 2 3 4 5"
145
+ # proby_task.save
146
+ def save
147
+ self.class.ensure_api_key_set
148
+ raise InvalidParameterException.new("name is required") if self.class.blank?(@name)
149
+ raise InvalidParameterException.new("crontab is required") if self.class.blank?(@crontab)
150
+
151
+ attributes = {
152
+ :name => @name,
153
+ :crontab => @crontab,
154
+ :time_zone => @time_zone,
155
+ :machine => @machine,
156
+ :finish_alarms_enabled => @finish_alarms_enabled,
157
+ :maximum_run_time => @maximum_run_time,
158
+ :start_notification_grace_period => @start_notification_grace_period,
159
+ :consecutive_alarmed_tasks_required_to_trigger_alarm => @consecutive_alarmed_tasks_required_to_trigger_alarm
160
+ }
161
+
162
+ Proby.logger.info "Updating task #{@api_id} with attributes: #{attributes.inspect}"
163
+ response = self.class.put("/api/v1/tasks/#{@api_id}.json",
164
+ :format => :json,
165
+ :body => MultiJson.encode(:task => attributes),
166
+ :headers => self.class.default_headers)
167
+
168
+ if response.code == 200
169
+ true
170
+ else
171
+ self.class.handle_api_failure(response)
172
+ end
173
+ end
174
+
175
+ # Delete a Proby task. The object will be frozen after the delete.
176
+ #
177
+ # @example
178
+ # proby_task = ProbyTask.get('my_proby_task_api_id')
179
+ # proby_task.delete
180
+ def delete
181
+ self.class.ensure_api_key_set
182
+
183
+ Proby.logger.info "Deleting task #{@api_id}"
184
+ response = self.class.delete("/api/v1/tasks/#{@api_id}.json",
185
+ :format => :json,
186
+ :headers => self.class.default_headers)
187
+
188
+ if response.code == 200
189
+ self.freeze
190
+ true
191
+ else
192
+ self.class.handle_api_failure(response)
193
+ end
194
+ end
195
+
196
+ # Pause a Proby task.
197
+ #
198
+ # @example
199
+ # proby_task = ProbyTask.get('my_proby_task_api_id')
200
+ # proby_task.pause
201
+ def pause
202
+ self.class.ensure_api_key_set
203
+
204
+ Proby.logger.info "Pausing task #{@api_id}"
205
+ response = self.class.post("/api/v1/tasks/#{@api_id}/pause.json",
206
+ :format => :json,
207
+ :headers => self.class.default_headers)
208
+
209
+ if response.code == 200
210
+ @paused = true
211
+ true
212
+ else
213
+ self.class.handle_api_failure(response)
214
+ end
215
+ end
216
+
217
+ # Unpause a Proby task.
218
+ #
219
+ # @example
220
+ # proby_task = ProbyTask.get('my_proby_task_api_id')
221
+ # proby_task.unpause
222
+ def unpause
223
+ self.class.ensure_api_key_set
224
+
225
+ Proby.logger.info "Unpausing task #{@api_id}"
226
+ response = self.class.post("/api/v1/tasks/#{@api_id}/unpause.json",
227
+ :format => :json,
228
+ :headers => self.class.default_headers)
229
+
230
+ if response.code == 200
231
+ @paused = false
232
+ true
233
+ else
234
+ self.class.handle_api_failure(response)
235
+ end
236
+ end
237
+
238
+ private
239
+
240
+ def self.list
241
+ Proby.logger.info "Getting the list of tasks"
242
+ response = get('/api/v1/tasks.json',
243
+ :format => :json,
244
+ :headers => default_headers)
245
+
246
+ if response.code == 200
247
+ data = response.parsed_response['tasks']
248
+ data.map { |task_data| new(task_data) }
249
+ else
250
+ handle_api_failure(response)
251
+ end
252
+ end
253
+
254
+ def self.fetch(api_id)
255
+ raise InvalidParameterException.new("api_id is required") if api_id.nil? || api_id.strip.empty?
256
+
257
+ Proby.logger.info "Fetching task from Proby: #{api_id}"
258
+ response = get("/api/v1/tasks/#{api_id}.json",
259
+ :format => :json,
260
+ :headers => default_headers)
261
+
262
+ if response.code == 200
263
+ new(response.parsed_response['task'])
264
+ elsif response.code == 404
265
+ nil
266
+ else
267
+ handle_api_failure(response)
268
+ end
269
+ end
270
+
271
+ def self.ensure_api_key_set
272
+ if Proby.api_key.nil? || Proby.api_key.strip.empty?
273
+ raise InvalidApiKeyException.new("Your Proby API key has not been set. Set it using Proby.api_key = 'my_api_key'")
274
+ end
275
+ end
276
+
277
+ end
278
+ end
@@ -0,0 +1,65 @@
1
+ module Proby
2
+ # Automatically notifies Proby when this job starts and finishes.
3
+ #
4
+ # class SomeJob
5
+ # extend Proby::ResquePlugin
6
+ #
7
+ # self.perform
8
+ # do_stuff
9
+ # end
10
+ # end
11
+ #
12
+ # The Proby Task ID can be set in one of two ways. The most common way is to
13
+ # put the ID in an ENV variable that is set in your crontab. This ID will be
14
+ # transparently passed to the Resque job via Redis.
15
+ #
16
+ # 0 0 * * * PROBY_TASK_ID=abc123 ./queue_some_job
17
+ #
18
+ # Alternatively, if you're not using cron and therefore don't want that
19
+ # support, you can just set the @proby_id ivar in the class, like so.
20
+ #
21
+ # class SomeJob
22
+ # extend Proby::ResquePlugin
23
+ # @proby_id = 'abc123'
24
+ #
25
+ # self.perform
26
+ # do_stuff
27
+ # end
28
+ # end
29
+ #
30
+ # Setting the @proby_id variable will take precendence over the ENV variable.
31
+ #
32
+ module ResquePlugin
33
+ def proby_id_bucket(*args)
34
+ "proby_id:#{name}-#{args.to_s}"
35
+ end
36
+
37
+ def before_enqueue_proby(*args)
38
+ return true if @proby_id
39
+
40
+ env_proby_id = ENV['PROBY_TASK_ID']
41
+ Resque.redis.setex(proby_id_bucket(*args), 24.hours, env_proby_id)
42
+ return true
43
+ end
44
+
45
+ def proby_id(*args)
46
+ @proby_id || Resque.redis.get(proby_id_bucket(*args))
47
+ end
48
+
49
+ def around_perform_proby(*args)
50
+ failed = false
51
+ error_message = nil
52
+ _proby_id = proby_id(*args)
53
+ Proby.send_start_notification(_proby_id)
54
+ yield
55
+ rescue Exception => e
56
+ failed = true
57
+ error_message = "#{e.class.name}: #{e.message}"
58
+ error_message << "\n#{e.backtrace.join("\n")}" if e.backtrace
59
+ raise e
60
+ ensure
61
+ Proby.send_finish_notification(_proby_id, :failed => failed, :error_message => error_message)
62
+ end
63
+ end
64
+ end
65
+
@@ -0,0 +1,3 @@
1
+ module Proby
2
+ VERSION = "2.0.0"
3
+ end
data/lib/proby.rb ADDED
@@ -0,0 +1,75 @@
1
+ require 'logger'
2
+ require 'httparty'
3
+ require 'chronic'
4
+
5
+ require 'proby/exceptions'
6
+ require 'proby/proby_http_api'
7
+ require 'proby/proby_task'
8
+ require 'proby/notifier'
9
+ require 'proby/resque_plugin'
10
+
11
+ module Proby
12
+
13
+ # A simple library for working with the Proby task monitoring application.
14
+ class << self
15
+
16
+ # Set your Proby API key.
17
+ #
18
+ # @param [String] api_key Your Proby API key
19
+ #
20
+ # @example
21
+ # Proby.api_key = '1234567890abcdefg'
22
+ def api_key=(api_key)
23
+ @api_key = api_key
24
+ end
25
+
26
+ # Get the api key.
27
+ def api_key
28
+ @api_key
29
+ end
30
+
31
+ # Set the logger to be used by Proby.
32
+ #
33
+ # @param [Logger] logger The logger you would like Proby to use
34
+ #
35
+ # @example
36
+ # Proby.logger = Rails.logger
37
+ # Proby.logger = Logger.new(STDERR)
38
+ def logger=(logger)
39
+ @logger = logger
40
+ end
41
+
42
+ # Get the logger used by Proby.
43
+ def logger
44
+ @logger ||= Logger.new("/dev/null")
45
+ end
46
+
47
+ # Send a start notification for this task to Proby.
48
+ #
49
+ # @param [String] proby_task_id The id of the task to be notified. If nil, the
50
+ # value of the +PROBY_TASK_ID+ environment variable will be used.
51
+ #
52
+ # @return [Fixnum] The HTTP status code that was returned from Proby.
53
+ def send_start_notification(proby_task_id=nil)
54
+ Notifier.send_notification('start', proby_task_id)
55
+ end
56
+
57
+ # Send a finish notification for this task to Proby
58
+ #
59
+ # @param [String] proby_task_id The id of the task to be notified. If nil, the
60
+ # value of the +PROBY_TASK_ID+ environment variable will be used.
61
+ # @param [Hash] options The options for the finish notification
62
+ # @option options [Boolean] :failed true if this task run resulted in some sort of failure. Setting
63
+ # this parameter to true will trigger a notification to be sent to
64
+ # the alarms configured for the given task. Defaults to false.
65
+ # @option options [String] :error_message A string message describing the failure that occurred.
66
+ # 1,000 character limit.
67
+ #
68
+ # @return [Fixnum] The HTTP status code that was returned from Proby.
69
+ def send_finish_notification(proby_task_id=nil, options={})
70
+ Notifier.send_notification('finish', proby_task_id, options)
71
+ end
72
+
73
+ end
74
+ end
75
+