td 0.7.5 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
data/ChangeLog CHANGED
@@ -1,4 +1,9 @@
1
1
 
2
+ == 2011-08-21 version 0.8.0
3
+
4
+ * Splits API libraries to td-client gem
5
+
6
+
2
7
  == 2011-08-18 version 0.7.5
3
8
 
4
9
  * set-schema: adds column instead of replacing all columns
@@ -1,7 +1,7 @@
1
1
 
2
2
  module TreasureData
3
3
 
4
- autoload :API, 'td/api'
4
+ autoload :API, 'td/client/api'
5
5
  autoload :Client, 'td/client'
6
6
  autoload :Database, 'td/client'
7
7
  autoload :Table, 'td/client'
data/lib/td/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module TreasureData
2
2
 
3
- VERSION = '0.7.5'
3
+ VERSION = '0.8.0'
4
4
 
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: td
3
3
  version: !ruby/object:Gem::Version
4
- hash: 9
4
+ hash: 63
5
5
  prerelease: false
6
6
  segments:
7
7
  - 0
8
- - 7
9
- - 5
10
- version: 0.7.5
8
+ - 8
9
+ - 0
10
+ version: 0.8.0
11
11
  platform: ruby
12
12
  authors:
13
13
  - Sadayuki Furuhashi
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2011-08-18 00:00:00 +09:00
18
+ date: 2011-08-21 00:00:00 +09:00
19
19
  default_executable: td
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -66,6 +66,22 @@ dependencies:
66
66
  version: 0.4.5
67
67
  type: :runtime
68
68
  version_requirements: *id003
69
+ - !ruby/object:Gem::Dependency
70
+ name: td-client
71
+ prerelease: false
72
+ requirement: &id004 !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ~>
76
+ - !ruby/object:Gem::Version
77
+ hash: 63
78
+ segments:
79
+ - 0
80
+ - 8
81
+ - 0
82
+ version: 0.8.0
83
+ type: :runtime
84
+ version_requirements: *id004
69
85
  description:
70
86
  email:
71
87
  executables:
@@ -76,8 +92,6 @@ extra_rdoc_files:
76
92
  - ChangeLog
77
93
  - README.rdoc
78
94
  files:
79
- - lib/td/api.rb
80
- - lib/td/client.rb
81
95
  - lib/td/command/account.rb
82
96
  - lib/td/command/common.rb
83
97
  - lib/td/command/database.rb
data/lib/td/api.rb DELETED
@@ -1,455 +0,0 @@
1
-
2
- module TreasureData
3
-
4
-
5
- class APIError < StandardError
6
- end
7
-
8
- class AuthError < APIError
9
- end
10
-
11
- class AlreadyExistsError < APIError
12
- end
13
-
14
- class NotFoundError < APIError
15
- end
16
-
17
-
18
- class API
19
- def initialize(apikey)
20
- require 'json'
21
- @apikey = apikey
22
- end
23
-
24
- # TODO error check & raise appropriate errors
25
-
26
- attr_reader :apikey
27
-
28
- def self.validate_database_name(name)
29
- name = name.to_s
30
- if name.empty?
31
- raise "Empty name is not allowed"
32
- end
33
- if name.length < 3 || 32 < name.length
34
- raise "Name must be 3 to 32 characters, got #{name.length} characters."
35
- end
36
- unless name =~ /^([a-z0-9_]+)$/
37
- raise "Name must consist only of alphabets, numbers, '_'."
38
- end
39
- name
40
- end
41
-
42
- def self.validate_table_name(name)
43
- validate_database_name(name)
44
- end
45
-
46
- def self.validate_column_name(name)
47
- name = name.to_s
48
- if name.empty?
49
- raise "Empty column name is not allowed"
50
- end
51
- if 32 < name.length
52
- raise "Column name must be to 32 characters, got #{name.length} characters."
53
- end
54
- unless name =~ /^([a-z0-9_]+)$/
55
- raise "Column name must consist only of alphabets, numbers, '_'."
56
- end
57
- end
58
-
59
- def self.normalize_type_name(name)
60
- case name
61
- when /int/i, /integer/i
62
- "int"
63
- when /long/i, /bigint/i
64
- "long"
65
- when /string/i
66
- "string"
67
- when /float/i
68
- "float"
69
- when /double/i
70
- "double"
71
- else
72
- raise "Type name must eather of int, long, string float or double"
73
- end
74
- end
75
-
76
- ####
77
- ## Database API
78
- ##
79
-
80
- # => [name:String]
81
- def list_databases
82
- code, body, res = get("/v3/database/list")
83
- if code != "200"
84
- raise_error("List databases failed", res)
85
- end
86
- # TODO format check
87
- js = JSON.load(body)
88
- names = js["databases"].map {|dbinfo| dbinfo['name'] }
89
- return names
90
- end
91
-
92
- # => true
93
- def delete_database(db)
94
- code, body, res = post("/v3/database/delete/#{e db}")
95
- if code != "200"
96
- raise_error("Delete database failed", res)
97
- end
98
- return true
99
- end
100
-
101
- # => true
102
- def create_database(db)
103
- code, body, res = post("/v3/database/create/#{e db}")
104
- if code != "200"
105
- raise_error("Create database failed", res)
106
- end
107
- return true
108
- end
109
-
110
-
111
- ####
112
- ## Table API
113
- ##
114
-
115
- # => {name:String => [type:Symbol, count:Integer]}
116
- def list_tables(db)
117
- code, body, res = get("/v3/table/list/#{e db}")
118
- if code != "200"
119
- raise_error("List tables failed", res)
120
- end
121
- # TODO format check
122
- js = JSON.load(body)
123
- result = {}
124
- js["tables"].map {|m|
125
- name = m['name']
126
- type = (m['type'] || '?').to_sym
127
- count = (m['count'] || 0).to_i # TODO?
128
- schema = JSON.parse(m['schema'] || '[]')
129
- result[name] = [type, schema, count]
130
- }
131
- return result
132
- end
133
-
134
- def create_log_or_item_table(db, table, type)
135
- code, body, res = post("/v3/table/create/#{e db}/#{e table}/#{type}")
136
- if code != "200"
137
- raise_error("Create #{type} table failed", res)
138
- end
139
- return true
140
- end
141
- private :create_log_or_item_table
142
-
143
- # => true
144
- def create_log_table(db, table)
145
- create_table(db, table, :log)
146
- end
147
-
148
- # => true
149
- def create_item_table(db, table)
150
- create_table(db, table, :item)
151
- end
152
-
153
- def create_table(db, table, type)
154
- schema = schema.to_s
155
- code, body, res = post("/v3/table/create/#{e db}/#{e table}/#{type}")
156
- if code != "200"
157
- raise_error("Create #{type} table failed", res)
158
- end
159
- return true
160
- end
161
- private :create_table
162
-
163
- # => true
164
- def update_schema(db, table, schema_json)
165
- code, body, res = post("/v3/table/update-schema/#{e db}/#{e table}", {'schema'=>schema_json})
166
- if code != "200"
167
- raise_error("Create schema table failed", res)
168
- end
169
- return true
170
- end
171
-
172
- # => type:Symbol
173
- def delete_table(db, table)
174
- code, body, res = post("/v3/table/delete/#{e db}/#{e table}")
175
- if code != "200"
176
- raise_error("Drop table failed", res)
177
- end
178
- # TODO format check
179
- js = JSON.load(body)
180
- type = (js['type'] || '?').to_sym
181
- return type
182
- end
183
-
184
-
185
- ####
186
- ## Job API
187
- ##
188
-
189
- # => [(jobId:String, type:Symbol, status:String, start_at:String, end_at:String)]
190
- def list_jobs(from=0, to=nil)
191
- params = {}
192
- params['from'] = from.to_s if from
193
- params['to'] = to.to_s if to
194
- code, body, res = get("/v3/job/list", params)
195
- if code != "200"
196
- raise_error("List jobs failed", res)
197
- end
198
- # TODO format check
199
- js = JSON.load(body)
200
- result = []
201
- js['jobs'].each {|m|
202
- job_id = m['job_id']
203
- type = (m['type'] || '?').to_sym
204
- status = m['status']
205
- query = m['query']
206
- start_at = m['start_at']
207
- end_at = m['end_at']
208
- result << [job_id, type, status, query, start_at, end_at]
209
- }
210
- return result
211
- end
212
-
213
- # => (type:Symbol, status:String, result:String, url:String)
214
- def show_job(job_id)
215
- code, body, res = get("/v3/job/show/#{e job_id}")
216
- if code != "200"
217
- raise_error("Show job failed", res)
218
- end
219
- # TODO format check
220
- js = JSON.load(body)
221
- # TODO debug
222
- type = (js['type'] || '?').to_sym # TODO
223
- query = js['query']
224
- status = js['status']
225
- debug = js['debug']
226
- url = js['url']
227
- start_at = js['start_at']
228
- end_at = js['end_at']
229
- return [type, query, status, url, debug, start_at, end_at]
230
- end
231
-
232
- def job_result(job_id)
233
- require 'msgpack'
234
- code, body, res = get("/v3/job/result/#{e job_id}", {'format'=>'msgpack'})
235
- if code != "200"
236
- raise_error("Get job result failed", res)
237
- end
238
- result = []
239
- MessagePack::Unpacker.new.feed_each(body) {|row|
240
- result << row
241
- }
242
- return result
243
- end
244
-
245
- def job_result_format(job_id, format)
246
- # TODO chunked encoding
247
- code, body, res = get("/v3/job/result/#{e job_id}", {'format'=>format})
248
- if code != "200"
249
- raise_error("Get job result failed", res)
250
- end
251
- return body
252
- end
253
-
254
- def job_result_each(job_id, &block)
255
- # TODO chunked encoding
256
- require 'msgpack'
257
- code, body, res = get("/v3/job/result/#{e job_id}", {'format'=>'msgpack'})
258
- if code != "200"
259
- raise_error("Get job result failed", res)
260
- end
261
- result = []
262
- MessagePack::Unpacker.new.feed_each(body) {|row|
263
- yield row
264
- }
265
- nil
266
- end
267
-
268
- def job_result_raw(job_id, format)
269
- code, body, res = get("/v3/job/result/#{e job_id}", {'format'=>format})
270
- if code != "200"
271
- raise_error("Get job result failed", res)
272
- end
273
- return body
274
- end
275
-
276
- # => jobId:String
277
- def hive_query(q, db=nil)
278
- code, body, res = post("/v3/job/issue/hive/#{e db}", {'query'=>q})
279
- if code != "200"
280
- raise_error("Query failed", res)
281
- end
282
- # TODO format check
283
- js = JSON.load(body)
284
- return js['job_id'].to_s
285
- end
286
-
287
-
288
- ####
289
- ## Import API
290
- ##
291
-
292
- # => time:Float
293
- def import(db, table, format, stream, stream_size=stream.lstat.size)
294
- code, body, res = put("/v3/table/import/#{e db}/#{e table}/#{format}", stream, stream_size)
295
- if code[0] != ?2
296
- raise_error("Import failed", res)
297
- end
298
- # TODO format check
299
- js = JSON.load(body)
300
- time = js['time'].to_f
301
- return time
302
- end
303
-
304
-
305
- ####
306
- ## User API
307
- ##
308
-
309
- # apikey:String
310
- def authenticate(user, password)
311
- code, body, res = post("/v3/user/authenticate", {'user'=>user, 'password'=>password})
312
- if code != "200"
313
- raise_error("Authentication failed", res)
314
- end
315
- # TODO format check
316
- js = JSON.load(body)
317
- apikey = js['apikey']
318
- return apikey
319
- end
320
-
321
- ####
322
- ## Server Status API
323
- ##
324
-
325
- # => status:String
326
- def server_status
327
- code, body, res = get('/v3/system/server_status')
328
- if code != "200"
329
- return "Server is down (#{code})"
330
- end
331
- # TODO format check
332
- js = JSON.load(body)
333
- status = js['status']
334
- return status
335
- end
336
-
337
- private
338
- host = 'api.treasure-data.com'
339
- port = 80
340
- if e = ENV['TD_API_SERVER']
341
- host, port_ = e.split(':',2)
342
- port_ = port_.to_i
343
- port = port_ if port_ != 0
344
- end
345
-
346
- HOST = host
347
- PORT = port
348
- USE_SSL = false
349
- BASE_URL = ''
350
-
351
- def get(url, params=nil)
352
- http, header = new_http
353
-
354
- path = BASE_URL + url
355
- if params && !params.empty?
356
- path << "?"+params.map {|k,v|
357
- "#{k}=#{e v}"
358
- }.join('&')
359
- end
360
-
361
- request = Net::HTTP::Get.new(path, header)
362
-
363
- response = http.request(request)
364
- return [response.code, response.body, response]
365
- end
366
-
367
- def post(url, params=nil)
368
- http, header = new_http
369
-
370
- path = BASE_URL + url
371
-
372
- request = Net::HTTP::Post.new(path, header)
373
- request.set_form_data(params) if params
374
-
375
- response = http.request(request)
376
- return [response.code, response.body, response]
377
- end
378
-
379
- def put(url, stream, stream_size)
380
- http, header = new_http
381
-
382
- path = BASE_URL + url
383
-
384
- header['Content-Type'] = 'application/octet-stream'
385
- header['Content-Length'] = stream_size.to_s
386
-
387
- request = Net::HTTP::Put.new(url, header)
388
- if request.respond_to?(:body_stream=)
389
- request.body_stream = stream
390
- else # Ruby 1.8
391
- request.body = stream.read
392
- end
393
-
394
- response = http.request(request)
395
- return [response.code, response.body, response]
396
- end
397
-
398
- def new_http
399
- require 'net/http'
400
- require 'time'
401
-
402
- http = Net::HTTP.new(HOST, PORT)
403
- if USE_SSL
404
- http.use_ssl = true
405
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
406
- store = OpenSSL::X509::Store.new
407
- http.cert_store = store
408
- end
409
-
410
- #http.read_timeout = options[:read_timeout]
411
-
412
- header = {}
413
- if @apikey
414
- header['Authorization'] = "TD1 #{apikey}"
415
- end
416
- header['Date'] = Time.now.rfc2822
417
-
418
- return http, header
419
- end
420
-
421
- def raise_error(msg, res)
422
- begin
423
- js = JSON.load(res.body)
424
- msg = js['message']
425
- error_code = js['error_code']
426
-
427
- if res.code == "404"
428
- raise NotFoundError, "#{error_code}: #{msg}"
429
- elsif res.code == "409"
430
- raise AlreadyExistsError, "#{error_code}: #{msg}"
431
- else
432
- raise APIError, "#{error_code}: #{msg}"
433
- end
434
-
435
- rescue
436
- if res.code == "404"
437
- raise NotFoundError, "#{msg}: #{res.body}"
438
- elsif res.code == "409"
439
- raise AlreadyExistsError, "#{msg}: #{res.body}"
440
- else
441
- raise APIError, "#{msg}: #{res.body}"
442
- end
443
- end
444
- # TODO error
445
- end
446
-
447
- def e(s)
448
- require 'cgi'
449
- CGI.escape(s.to_s)
450
- end
451
- end
452
-
453
-
454
- end
455
-
data/lib/td/client.rb DELETED
@@ -1,380 +0,0 @@
1
- require 'time'
2
- require 'td/api'
3
-
4
- module TreasureData
5
-
6
- class Client
7
- def self.authenticate(user, password)
8
- api = API.new(nil)
9
- apikey = api.authenticate(user, password)
10
- new(apikey)
11
- end
12
-
13
- def self.server_status
14
- api = API.new(nil)
15
- api.server_status
16
- end
17
-
18
- def initialize(apikey)
19
- @api = API.new(apikey)
20
- end
21
-
22
- attr_reader :api
23
-
24
- def apikey
25
- @api.apikey
26
- end
27
-
28
- def server_status
29
- @api.server_status
30
- end
31
-
32
- # => true
33
- def create_database(db_name)
34
- @api.create_database(db_name)
35
- end
36
-
37
- # => true
38
- def delete_database(db_name)
39
- @api.delete_database(db_name)
40
- end
41
-
42
- # => [Database]
43
- def databases
44
- names = @api.list_databases
45
- names.map {|db_name|
46
- Database.new(self, db_name)
47
- }
48
- end
49
-
50
- # => Database
51
- def database(db_name)
52
- names = @api.list_databases
53
- names.each {|n|
54
- if n == db_name
55
- return Database.new(self, db_name)
56
- end
57
- }
58
- raise NotFoundError, "Database '#{db_name}' does not exist"
59
- end
60
-
61
- # => true
62
- def create_log_table(db_name, table_name)
63
- @api.create_log_table(db_name, table_name)
64
- end
65
-
66
- # => true
67
- def create_item_table(db_name, table_name)
68
- @api.create_item_table(db_name, table_name)
69
- end
70
-
71
- # => true
72
- def update_schema(db_name, table_name, schema)
73
- @api.update_schema(db_name, table_name, schema.to_json)
74
- end
75
-
76
- # => type:Symbol
77
- def delete_table(db_name, table_name)
78
- @api.delete_table(db_name, table_name)
79
- end
80
-
81
- # => [Table]
82
- def tables(db_name)
83
- m = @api.list_tables(db_name)
84
- m.map {|table_name,(type,schema,count)|
85
- schema = Schema.new.from_json(schema)
86
- Table.new(self, db_name, table_name, type, schema, count)
87
- }
88
- end
89
-
90
- # => Table
91
- def table(db_name, table_name)
92
- tables(db_name).each {|t|
93
- if t.name == table_name
94
- return t
95
- end
96
- }
97
- raise NotFoundError, "Table '#{db_name}.#{table_name}' does not exist"
98
- end
99
-
100
- # => Job
101
- def query(db_name, q)
102
- job_id = @api.hive_query(q, db_name)
103
- Job.new(self, job_id, :hive, q) # TODO url
104
- end
105
-
106
- # => [Job=]
107
- def jobs(from=nil, to=nil)
108
- js = @api.list_jobs(from, to)
109
- js.map {|job_id,type,status,query,start_at,end_at|
110
- Job.new(self, job_id, type, query, status, nil, nil, start_at, end_at)
111
- }
112
- end
113
-
114
- # => Job
115
- def job(job_id)
116
- job_id = job_id.to_s
117
- type, query, status, url, debug, start_at, end_at = @api.show_job(job_id)
118
- Job.new(self, job_id, type, query, status, url, debug, start_at, end_at)
119
- end
120
-
121
- # => type:Symbol, url:String
122
- def job_status(job_id)
123
- type, query, status, url, debug, start_at, end_at = @api.show_job(job_id)
124
- return query, status, url, debug, start_at, end_at
125
- end
126
-
127
- # => result:[{column:String=>value:Object]
128
- def job_result(job_id)
129
- @api.job_result(job_id)
130
- end
131
-
132
- # => result:String
133
- def job_result_format(job_id, format)
134
- @api.job_result_format(job_id, format)
135
- end
136
-
137
- # => nil
138
- def job_result_each(job_id, &block)
139
- @api.job_result_each(job_id, &block)
140
- end
141
-
142
- # => time:Flaot
143
- def import(db_name, table_name, format, stream, stream_size=stream.lstat.size)
144
- @api.import(db_name, table_name, format, stream, stream_size)
145
- end
146
- end
147
-
148
-
149
- class Model
150
- def initialize(client)
151
- @client = client
152
- end
153
- end
154
-
155
- class Database < Model
156
- def initialize(client, db_name, tables=nil)
157
- super(client)
158
- @db_name = db_name
159
- @tables = tables
160
- end
161
-
162
- def name
163
- @db_name
164
- end
165
-
166
- def tables
167
- update_tables! unless @tables
168
- @tables
169
- end
170
-
171
- def create_log_table(name)
172
- @client.create_log_table(@db_name, name)
173
- end
174
-
175
- def create_item_table(name)
176
- @client.create_item_table(@db_name, name)
177
- end
178
-
179
- def table(table_name)
180
- @client.table(@db_name, table_name)
181
- end
182
-
183
- def delete
184
- @client.delete_database(@db_name)
185
- end
186
-
187
- def update_tables!
188
- @tables = @client.tables(@db_name)
189
- end
190
- end
191
-
192
- class Table < Model
193
- def initialize(client, db_name, table_name, type, schema, count)
194
- super(client)
195
- @db_name = db_name
196
- @table_name = table_name
197
- @type = type
198
- @schema = schema
199
- @count = count
200
- end
201
-
202
- attr_reader :type, :db_name, :table_name, :schema, :count
203
-
204
- alias database_name db_name
205
- alias name table_name
206
-
207
- def database
208
- @client.database(@db_name)
209
- end
210
-
211
- def identifier
212
- "#{@db_name}.#{@table_name}"
213
- end
214
-
215
- def delete
216
- @client.delete_table(@db_name, @table_name)
217
- end
218
- end
219
-
220
- class Schema
221
- class Field
222
- def initialize(name, type)
223
- @name = name
224
- @type = type
225
- end
226
- attr_reader :name
227
- attr_reader :type
228
- end
229
-
230
- def self.parse(cols)
231
- fields = cols.split(',').map {|col|
232
- name, type, *_ = col.split(':')
233
- Field.new(name, type)
234
- }
235
- Schema.new(fields)
236
- end
237
-
238
- def initialize(fields=[])
239
- @fields = fields
240
- end
241
-
242
- attr_reader :fields
243
-
244
- def add_field(name, type)
245
- @fields << Field.new(name, type)
246
- end
247
-
248
- def merge(schema)
249
- nf = @fields.dup
250
- schema.fields.each {|f|
251
- if i = nf.find_index {|sf| sf.name == f.name }
252
- nf[i] = f
253
- else
254
- nf << f
255
- end
256
- }
257
- Schema.new(nf)
258
- end
259
-
260
- def to_json(*args)
261
- @fields.map {|f| [f.name, f.type] }.to_json(*args)
262
- end
263
-
264
- def from_json(obj)
265
- @fields = obj.map {|f|
266
- Field.new(f[0], f[1])
267
- }
268
- self
269
- end
270
- end
271
-
272
- class Job < Model
273
- def initialize(client, job_id, type, query, status=nil, url=nil, debug=nil, start_at=nil, end_at=nil, result=nil)
274
- super(client)
275
- @job_id = job_id
276
- @type = type
277
- @url = url
278
- @query = query
279
- @status = status
280
- @debug = debug
281
- @start_at = start_at
282
- @end_at = end_at
283
- @result = result
284
- end
285
-
286
- attr_reader :job_id, :type
287
-
288
- def wait(timeout=nil)
289
- # TODO
290
- end
291
-
292
- def query
293
- update_status! unless @query
294
- @query
295
- end
296
-
297
- def status
298
- update_status! unless @status
299
- @status
300
- end
301
-
302
- def url
303
- update_status! unless @url
304
- @url
305
- end
306
-
307
- def debug
308
- update_status! unless @debug
309
- @debug
310
- end
311
-
312
- def start_at
313
- update_status! unless @start_at
314
- @start_at && !@start_at.empty? ? Time.parse(@start_at) : nil
315
- end
316
-
317
- def end_at
318
- update_status! unless @end_at
319
- @end_at && !@end_at.empty? ? Time.parse(@end_at) : nil
320
- end
321
-
322
- def result
323
- unless @result
324
- return nil unless finished?
325
- @result = @client.job_result(@job_id)
326
- end
327
- @result
328
- end
329
-
330
- def result_format(format)
331
- return nil unless finished?
332
- @client.job_result_format(@job_id, format)
333
- end
334
-
335
- def result_each(&block)
336
- if @result
337
- @result.each(&block)
338
- else
339
- @client.job_result_each(@job_id, &block)
340
- end
341
- nil
342
- end
343
-
344
- def finished?
345
- update_status! unless @status
346
- if @status == "success" || @status == "error"
347
- return true
348
- else
349
- return false
350
- end
351
- end
352
-
353
- def running?
354
- !finished?
355
- end
356
-
357
- def success?
358
- update_status! unless @status
359
- @status == "success"
360
- end
361
-
362
- def error?
363
- update_status! unless @status
364
- @status == "error"
365
- end
366
-
367
- def update_status!
368
- query, status, url, debug, start_at, end_at = @client.job_status(@job_id)
369
- @query = query
370
- @status = status
371
- @url = url
372
- @debug = debug
373
- @start_at = start_at
374
- @end_at = end_at
375
- self
376
- end
377
- end
378
-
379
- end
380
-