mortar 0.2.0 → 0.2.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.
@@ -128,6 +128,7 @@ module Mortar
128
128
  def self.prepare_run(cmd, args=[])
129
129
  command = parse(cmd)
130
130
 
131
+
131
132
  if args.include?('-h') || args.include?('--help')
132
133
  args.unshift(cmd) unless cmd =~ /^-.*/
133
134
  cmd = 'help'
@@ -138,6 +139,15 @@ module Mortar
138
139
  if %w( -v --version ).include?(cmd)
139
140
  cmd = 'version'
140
141
  command = parse(cmd)
142
+ # Check if the command tried matches a command file. If it does, the command exists, but doesn't have an index action
143
+ # Otherwise it would have been picked up by the original parse command.
144
+ elsif Dir[File.join(File.dirname(__FILE__), "command", "*.rb")].find { |file| file.include?(cmd) }
145
+ display "#{cmd} command requires arguments"
146
+ display
147
+ # Display the command's help message
148
+ args.unshift(cmd) unless cmd =~ /^-.*/
149
+ cmd = 'help'
150
+ command = parse('help')
141
151
  else
142
152
  error([
143
153
  "`#{cmd}` is not a mortar command.",
@@ -36,8 +36,13 @@ class Mortar::Command::Clusters < Mortar::Command::Base
36
36
  validate_arguments!
37
37
 
38
38
  clusters = api.get_clusters().body['clusters']
39
- display_table(clusters,
39
+ if not clusters.empty?
40
+ display_table(clusters,
40
41
  %w( cluster_id size status_description cluster_type_description start_timestamp duration),
41
42
  ['cluster_id', 'Size (# of Nodes)', 'Status', 'Type', 'Start Timestamp', 'Elapsed Time'])
43
+ else
44
+ display("There are no running or recent clusters")
45
+ end
46
+
42
47
  end
43
48
  end
@@ -66,9 +66,7 @@ class Mortar::Command::Describe < Mortar::Command::Base
66
66
  is_finished =
67
67
  Mortar::API::Describe::STATUSES_COMPLETE.include?(describe_result["status_code"])
68
68
 
69
- redisplay("Status: %s %s" % [
70
- describe_result['status_description'] + (is_finished ? "" : "..."),
71
- is_finished ? " " : spinner(ticks)],
69
+ redisplay("[#{spinner(ticks)}] Calculating schema for #{alias_name} and ancestors...",
72
70
  is_finished) # only display newline on last message
73
71
  if is_finished
74
72
  display
@@ -124,7 +124,7 @@ class Mortar::Command::Jobs < Mortar::Command::Base
124
124
  display
125
125
  display("Job status can be viewed on the web at:\n\n #{response['web_job_url']}")
126
126
  display
127
- display("Or by running:\n\n mortar jobs:status #{response['job_id']}")
127
+ display("Or by running:\n\n mortar jobs:status #{response['job_id']} --poll")
128
128
  display
129
129
  end
130
130
 
@@ -135,6 +135,9 @@ class Mortar::Command::Jobs < Mortar::Command::Base
135
135
  #
136
136
  # Check the status of a job.
137
137
  #
138
+ # -p, --poll # Poll the status of a job
139
+ #
140
+ #
138
141
  #Examples:
139
142
  #
140
143
  # $ mortar jobs:status 2000cbbba40a860a6f000000
@@ -149,43 +152,80 @@ class Mortar::Command::Jobs < Mortar::Command::Base
149
152
  error("Usage: mortar jobs:status JOB_ID\nMust specify JOB_ID.")
150
153
  end
151
154
 
152
- job_status = api.get_job(job_id).body
153
-
154
- job_display_entries = {
155
- "status" => job_status["status_description"],
156
- "progress" => "#{job_status["progress"]}%",
157
- "cluster_id" => job_status["cluster_id"],
158
- "job submitted at" => job_status["start_timestamp"],
159
- "job began running at" => job_status["running_timestamp"],
160
- "job finished at" => job_status["stop_timestamp"],
161
- "job running for" => job_status["duration"],
162
- "job run with parameters" => job_status["parameters"],
163
- "error" => job_status["error"]
164
- }
165
-
166
- unless job_status["error"].nil? || job_status["error"]["message"].nil?
167
- error_context = get_error_message_context(job_status["error"]["message"])
168
- unless error_context == ""
169
- job_status["error"]["help"] = error_context
155
+ # Inner function to display the hash table when the job is complte
156
+ def display_job_status(job_status)
157
+ job_display_entries = {
158
+ "status" => job_status["status_description"],
159
+ "progress" => "#{job_status["progress"]}%",
160
+ "cluster_id" => job_status["cluster_id"],
161
+ "job submitted at" => job_status["start_timestamp"],
162
+ "job began running at" => job_status["running_timestamp"],
163
+ "job finished at" => job_status["stop_timestamp"],
164
+ "job running for" => job_status["duration"],
165
+ "job run with parameters" => job_status["parameters"],
166
+ }
167
+
168
+
169
+ unless job_status["error"].nil? || job_status["error"]["message"].nil?
170
+ error_context = get_error_message_context(job_status["error"]["message"])
171
+ unless error_context == ""
172
+ job_status["error"]["help"] = error_context
173
+ end
174
+ job_status["error"].each_pair do |key, value|
175
+ job_display_entries["error - #{key}"] = value
176
+ end
170
177
  end
178
+
179
+ if job_status["num_hadoop_jobs"] && job_status["num_hadoop_jobs_succeeded"]
180
+ job_display_entries["hadoop jobs complete"] =
181
+ '%0.2f / %0.2f' % [job_status["num_hadoop_jobs_succeeded"], job_status["num_hadoop_jobs"]]
182
+ end
183
+
184
+ if job_status["outputs"] && job_status["outputs"].length > 0
185
+ job_display_entries["outputs"] = Hash[job_status["outputs"].select{|o| o["alias"]}.collect do |output|
186
+ output_hash = {}
187
+ output_hash["location"] = output["location"] if output["location"]
188
+ output_hash["records"] = output["records"] if output["records"]
189
+ [output['alias'], output_hash]
190
+ end]
191
+ end
192
+
193
+ styled_header("#{job_status["project_name"]}: #{job_status["pigscript_name"]} (job_id: #{job_status["job_id"]})")
194
+ styled_hash(job_display_entries)
171
195
  end
172
196
 
173
- if job_status["num_hadoop_jobs"] && job_status["num_hadoop_jobs_succeeded"]
174
- job_display_entries["hadoop jobs complete"] =
175
- '%0.2f / %0.2f' % [job_status["num_hadoop_jobs_succeeded"], job_status["num_hadoop_jobs"]]
176
- end
177
-
178
- if job_status["outputs"] && job_status["outputs"].length > 0
179
- job_display_entries["outputs"] = Hash[job_status["outputs"].select{|o| o["alias"]}.collect do |output|
180
- output_hash = {}
181
- output_hash["location"] = output["location"] if output["location"]
182
- output_hash["records"] = output["records"] if output["records"]
183
- [output['alias'], output_hash]
184
- end]
197
+ # If polling the status
198
+ if options[:poll]
199
+ ticking(polling_interval) do |ticks|
200
+ job_status = api.get_job(job_id).body
201
+ # If the job is complete exit and display the table normally
202
+ if Mortar::API::Jobs::STATUSES_COMPLETE.include?(job_status["status_code"] )
203
+ redisplay("")
204
+ display_job_status(job_status)
205
+ break
206
+ end
207
+
208
+ # If the job is running show the progress bar
209
+ if job_status["status_code"] == Mortar::API::Jobs::STATUS_RUNNING
210
+ progressbar = "=" + ("=" * (job_status["progress"].to_i / 5)) + ">"
211
+
212
+ if job_status["num_hadoop_jobs"] && job_status["num_hadoop_jobs_succeeded"]
213
+ hadoop_jobs_ratio_complete =
214
+ '%0.2f / %0.2f' % [job_status["num_hadoop_jobs_succeeded"], job_status["num_hadoop_jobs"]]
215
+ end
216
+
217
+ printf("\r[#{spinner(ticks)}] Status: [%-22s] %s%% Complete (%s MapReduce jobs finished)", progressbar, job_status["progress"], hadoop_jobs_ratio_complete)
218
+
219
+ # If the job is not complete, but not in the running state, just display its status
220
+ else
221
+ redisplay("[#{spinner(ticks)}] Status: #{job_status['status_description']}")
222
+ end
223
+ end
224
+ # If not polling, get the job status and display the results
225
+ else
226
+ job_status = api.get_job(job_id).body
227
+ display_job_status(job_status)
185
228
  end
186
-
187
- styled_header("#{job_status["project_name"]}: #{job_status["pigscript_name"]} (job_id: #{job_status["job_id"]})")
188
- styled_hash(job_display_entries)
189
229
  end
190
230
 
191
231
  # jobs:stop JOB_ID
@@ -105,6 +105,41 @@ class Mortar::Command::Projects < Mortar::Command::Base
105
105
  end
106
106
 
107
107
  end
108
+
109
+ # projects:set_remote PROJECT
110
+ #
111
+ # Adds the Mortar remote to the local git project. This is necessary for successfully executing many of the Mortar commands.
112
+ #
113
+ #Example:
114
+ #
115
+ # $ mortar projects:set_remote my_project
116
+ #
117
+ def set_remote
118
+ project_name = shift_argument
119
+
120
+ unless project_name
121
+ error("Usage: mortar projects:set_remote PROJECT\nMust specify PROJECT.")
122
+ end
123
+
124
+ unless git.has_dot_git?
125
+ error("Can only set the remote for an existing git project. Please run:\n\ngit init\ngit add .\ngit commit -a -m \"first commit\"\n\nto initialize your project in git.")
126
+ end
127
+
128
+ if git.remotes(git_organization).include?("mortar")
129
+ display("The remote has already been set for project: #{project_name}")
130
+ return
131
+ end
132
+
133
+ projects = api.get_projects().body["projects"]
134
+ project = projects.find { |p| p['name'] == project_name}
135
+ unless project
136
+ error("No project named: #{project_name} exists. You can create this project using:\n\n mortar projects:create")
137
+ end
138
+
139
+ git.remote_add("mortar", project['git_url'])
140
+ display("Successfully added the mortar remote to the #{project_name} project")
141
+
142
+ end
108
143
 
109
144
  # projects:clone PROJECT
110
145
  #
@@ -66,10 +66,8 @@ class Mortar::Command::Validate < Mortar::Command::Base
66
66
  validate_result = api.get_validate(validate_id).body
67
67
  is_finished =
68
68
  Mortar::API::Validate::STATUSES_COMPLETE.include?(validate_result["status_code"])
69
-
70
- redisplay("Status: %s %s" % [
71
- validate_result['status_description'] + (is_finished ? "" : "..."),
72
- is_finished ? " " : spinner(ticks)],
69
+
70
+ redisplay("[#{spinner(ticks)}] Checking your script for problems with: Pig syntax, Python syntax, and S3 data access",
73
71
  is_finished) # only display newline on last message
74
72
  if is_finished
75
73
  display
@@ -12,7 +12,7 @@
12
12
  * User-Defined Functions (UDFs)
13
13
  */
14
14
 
15
- REGISTER '../udfs/python/<%= script_name %>.py' using streaming_python as <%= script_name %>;
15
+ REGISTER '../udfs/python/<%= script_name %>.py' USING streaming_python AS <%= script_name %>;
16
16
  <% end %>
17
17
 
18
18
  -- This is an example of loading up input data
@@ -1,3 +1,4 @@
1
+ Gemfile.lock
1
2
  *.pyc
2
3
  *.class
3
4
  *.log
@@ -10,7 +10,7 @@
10
10
  /**
11
11
  * User-Defined Functions (UDFs)
12
12
  */
13
- REGISTER '../udfs/python/<%= project_name %>.py' using streaming_python as <%= project_name %>;
13
+ REGISTER '../udfs/python/<%= project_name %>.py' USING streaming_python AS <%= project_name %>;
14
14
 
15
15
  -- This is an example of loading up input data
16
16
  my_input_data = LOAD '$INPUT_PATH'
@@ -16,5 +16,5 @@
16
16
 
17
17
  module Mortar
18
18
  # see http://semver.org/
19
- VERSION = "0.2.0"
19
+ VERSION = "0.2.1"
20
20
  end
@@ -52,8 +52,7 @@ STDOUT
52
52
  mock(Mortar::Auth.api).get_clusters().returns(Excon::Response.new(:body => {"clusters" => []}))
53
53
  stderr, stdout = execute("clusters", nil, nil)
54
54
  stdout.should == <<-STDOUT
55
- cluster_id Size (# of Nodes) Status Type Start Timestamp Elapsed Time
56
- ---------- ----------------- ------ ---- --------------- ------------
55
+ There are no running or recent clusters
57
56
  STDOUT
58
57
  end
59
58
  end
@@ -86,7 +86,7 @@ Taking code snapshot... done
86
86
  Sending code snapshot to Mortar... done
87
87
  Starting describe... done
88
88
 
89
- \r\e[0KStatus: Pending... /\r\e[0KStatus: Gateway starting... -\r\e[0KStatus: Starting pig... \\\r\e[0KStatus: Success
89
+ \r\e[0K[/] Calculating schema for my_alias and ancestors...\r\e[0K[-] Calculating schema for my_alias and ancestors...\r\e[0K[\\] Calculating schema for my_alias and ancestors...\r\e[0K[|] Calculating schema for my_alias and ancestors...
90
90
 
91
91
  Results available at https://api.mortardata.com/describe/c571a8c7f76a4fd4a67c103d753e2dd5
92
92
  Opening web browser to show results... done
@@ -119,7 +119,7 @@ Taking code snapshot... done
119
119
  Sending code snapshot to Mortar... done
120
120
  Starting describe... done
121
121
 
122
- \r\e[0KStatus: Pending... /\r\e[0KStatus: Failed
122
+ \r\e[0K[/] Calculating schema for my_alias and ancestors...\r\e[0K[-] Calculating schema for my_alias and ancestors...
123
123
 
124
124
  STDOUT
125
125
  stderr.should == <<-STDERR
@@ -56,7 +56,7 @@ Job status can be viewed on the web at:
56
56
 
57
57
  Or by running:
58
58
 
59
- mortar jobs:status c571a8c7f76a4fd4a67c103d753e2dd5
59
+ mortar jobs:status c571a8c7f76a4fd4a67c103d753e2dd5 --poll
60
60
 
61
61
  STDOUT
62
62
  end
@@ -87,7 +87,7 @@ Job status can be viewed on the web at:
87
87
 
88
88
  Or by running:
89
89
 
90
- mortar jobs:status c571a8c7f76a4fd4a67c103d753e2dd5
90
+ mortar jobs:status c571a8c7f76a4fd4a67c103d753e2dd5 --poll
91
91
 
92
92
  STDOUT
93
93
  end
@@ -116,7 +116,7 @@ Job status can be viewed on the web at:
116
116
 
117
117
  Or by running:
118
118
 
119
- mortar jobs:status c571a8c7f76a4fd4a67c103d753e2dd5
119
+ mortar jobs:status c571a8c7f76a4fd4a67c103d753e2dd5 --poll
120
120
 
121
121
  STDOUT
122
122
  end
@@ -339,11 +339,10 @@ STDOUT
339
339
  stdout.should == <<-STDOUT
340
340
  === myproject: my_script (job_id: c571a8c7f76a4fd4a67c103d753e2dd5)
341
341
  cluster_id: e2790e7e8c7d48e39157238d58191346
342
- error:
343
- column_number: 34
344
- line_number: 43
345
- message: An error occurred and here's some more info
346
- type: RuntimeError
342
+ error - column_number: 34
343
+ error - line_number: 43
344
+ error - message: An error occurred and here's some more info
345
+ error - type: RuntimeError
347
346
  hadoop jobs complete: 0.00 / 4.00
348
347
  job began running at: 2012-02-28T03:41:52.613000+00:00
349
348
  job finished at: 2012-02-28T03:45:52.613000+00:00
@@ -354,6 +353,85 @@ job running for: 6 mins
354
353
  job submitted at: 2012-02-28T03:35:42.831000+00:00
355
354
  progress: 55%
356
355
  status: Execution error
356
+ STDOUT
357
+ end
358
+ end
359
+ it "gets status for a running job using polling" do
360
+ with_git_initialized_project do |p|
361
+ job_id = "c571a8c7f76a4fd4a67c103d753e2dd5"
362
+ pigscript_name = "my_script"
363
+ project_name = "myproject"
364
+ status_code = Mortar::API::Jobs::STATUS_RUNNING
365
+ progress = 0
366
+ cluster_id = "e2790e7e8c7d48e39157238d58191346"
367
+ start_timestamp = "2012-02-28T03:35:42.831000+00:00"
368
+ stop_timestamp = "2012-02-28T03:44:52.613000+00:00"
369
+ running_timestamp = "2012-02-28T03:41:52.613000+00:00"
370
+ parameters = {"my_param_1" => "value1", "MY_PARAM_2" => "3"}
371
+
372
+ mock(Mortar::Auth.api).get_job(job_id).returns(Excon::Response.new(:body => {"job_id" => job_id,
373
+ "pigscript_name" => pigscript_name,
374
+ "project_name" => project_name,
375
+ "status_code" => status_code,
376
+ "status_description" => "Execution error",
377
+ "progress" => progress,
378
+ "cluster_id" => cluster_id,
379
+ "start_timestamp" => start_timestamp,
380
+ "running_timestamp" => running_timestamp,
381
+ "duration" => "6 mins",
382
+ "num_hadoop_jobs" => 4,
383
+ "num_hadoop_jobs_succeeded" => 0,
384
+ "parameters" => parameters
385
+ }))
386
+
387
+ status_code = Mortar::API::Jobs::STATUS_SUCCESS
388
+ progress = 100
389
+ outputs = [{'name'=> 'hottest_songs_of_the_decade',
390
+ 'records' => 10,
391
+ 'alias' => 'output_data',
392
+ 'location' => 's3n://my-bucket/my-folder/hottest_songs_of_the_decade/output_data'},
393
+ {'name'=> 'hottest_songs_of_the_decade',
394
+ 'records' => 100,
395
+ 'alias' => 'output_data_2',
396
+ 'location' => 's3n://my-bucket/my-folder/hottest_songs_of_the_decade/output_data_2'}]
397
+
398
+ mock(Mortar::Auth.api).get_job(job_id).returns(Excon::Response.new(:body => {"job_id" => job_id,
399
+ "pigscript_name" => pigscript_name,
400
+ "project_name" => project_name,
401
+ "status_code" => status_code,
402
+ "status_description" => "Success",
403
+ "progress" => progress,
404
+ "cluster_id" => cluster_id,
405
+ "start_timestamp" => start_timestamp,
406
+ "running_timestamp" => running_timestamp,
407
+ "stop_timestamp" => stop_timestamp,
408
+ "duration" => "6 mins",
409
+ "num_hadoop_jobs" => 4,
410
+ "num_hadoop_jobs_succeeded" => 4,
411
+ "parameters" => parameters,
412
+ "outputs" => outputs
413
+ }))
414
+ stderr, stdout = execute("jobs:status c571a8c7f76a4fd4a67c103d753e2dd5 -p", p, @git)
415
+ stdout.should == <<-STDOUT
416
+ \r[/] Status: [=> ] 0% Complete (0.00 / 4.00 MapReduce jobs finished)\r\e[0K=== myproject: my_script (job_id: c571a8c7f76a4fd4a67c103d753e2dd5)
417
+ cluster_id: e2790e7e8c7d48e39157238d58191346
418
+ hadoop jobs complete: 4.00 / 4.00
419
+ job began running at: 2012-02-28T03:41:52.613000+00:00
420
+ job finished at: 2012-02-28T03:44:52.613000+00:00
421
+ job run with parameters:
422
+ MY_PARAM_2: 3
423
+ my_param_1: value1
424
+ job running for: 6 mins
425
+ job submitted at: 2012-02-28T03:35:42.831000+00:00
426
+ outputs:
427
+ output_data:
428
+ location: s3n://my-bucket/my-folder/hottest_songs_of_the_decade/output_data
429
+ records: 10
430
+ output_data_2:
431
+ location: s3n://my-bucket/my-folder/hottest_songs_of_the_decade/output_data_2
432
+ records: 100
433
+ progress: 100%
434
+ status: Success
357
435
  STDOUT
358
436
  end
359
437
  end
@@ -380,7 +458,6 @@ STDOUT
380
458
 
381
459
 
382
460
  end
383
-
384
461
  end
385
462
  end
386
463
  end
@@ -129,6 +129,62 @@ STDOUT
129
129
  end
130
130
 
131
131
  end
132
+
133
+ context("set_remote") do
134
+
135
+ it "sets the remote of a project" do
136
+ with_git_initialized_project do |p|
137
+ project_name = p.name
138
+ project_git_url = "git@github.com:mortarcode-dev/#{project_name}"
139
+ `git remote rm mortar`
140
+ mock(Mortar::Auth.api).get_projects().returns(Excon::Response.new(:body => {"projects" => [ { "name" => project_name, "status" => Mortar::API::Projects::STATUS_ACTIVE, "git_url" => project_git_url } ] })).ordered
141
+
142
+ mock(@git).remote_add("mortar", project_git_url)
143
+
144
+ stderr, stdout = execute("projects:set_remote #{project_name}", p, @git)
145
+ stdout.should == <<-STDOUT
146
+ Successfully added the mortar remote to the myproject project
147
+ STDOUT
148
+ end
149
+ end
150
+
151
+ it "remote already added" do
152
+ with_git_initialized_project do |p|
153
+ project_name = p.name
154
+
155
+ stderr, stdout = execute("projects:set_remote #{project_name}", p, @git)
156
+ stdout.should == <<-STDERR
157
+ The remote has already been set for project: myproject
158
+ STDERR
159
+ end
160
+ end
161
+
162
+ it "No project given" do
163
+ with_git_initialized_project do |p|
164
+ stderr, stdout = execute("projects:set_remote", p, @git)
165
+ stderr.should == <<-STDERR
166
+ ! Usage: mortar projects:set_remote PROJECT
167
+ ! Must specify PROJECT.
168
+ STDERR
169
+ end
170
+ end
171
+
172
+ it "No project with that name" do
173
+ with_git_initialized_project do |p|
174
+ project_name = p.name
175
+ project_git_url = "git@github.com:mortarcode-dev/#{project_name}"
176
+ mock(Mortar::Auth.api).get_projects().returns(Excon::Response.new(:body => {"projects" => [ { "name" => "derp", "status" => Mortar::API::Projects::STATUS_ACTIVE, "git_url" => project_git_url } ] })).ordered
177
+ `git remote rm mortar`
178
+
179
+ stderr, stdout = execute("projects:set_remote #{project_name}", p, @git)
180
+ stderr.should == <<-STDERR
181
+ ! No project named: myproject exists. You can create this project using:
182
+ !
183
+ ! mortar projects:create
184
+ STDERR
185
+ end
186
+ end
187
+ end
132
188
 
133
189
 
134
190
  context("clone") do
@@ -71,7 +71,7 @@ Taking code snapshot... done
71
71
  Sending code snapshot to Mortar... done
72
72
  Starting validate... done
73
73
 
74
- \r\e[0KStatus: Pending... /\r\e[0KStatus: GATEWAY_STARTING... -\r\e[0KStatus: Starting... \\\r\e[0KStatus: Success
74
+ \r\e[0K[/] Checking your script for problems with: Pig syntax, Python syntax, and S3 data access\r\e[0K[-] Checking your script for problems with: Pig syntax, Python syntax, and S3 data access\r\e[0K[\\] Checking your script for problems with: Pig syntax, Python syntax, and S3 data access\r\e[0K[|] Checking your script for problems with: Pig syntax, Python syntax, and S3 data access
75
75
 
76
76
  Your script is valid.
77
77
  STDOUT
@@ -103,7 +103,7 @@ Taking code snapshot... done
103
103
  Sending code snapshot to Mortar... done
104
104
  Starting validate... done
105
105
 
106
- \r\e[0KStatus: Pending... /\r\e[0KStatus: Failed
106
+ \r\e[0K[/] Checking your script for problems with: Pig syntax, Python syntax, and S3 data access\r\e[0K[-] Checking your script for problems with: Pig syntax, Python syntax, and S3 data access
107
107
 
108
108
  STDOUT
109
109
  stderr.should == <<-STDERR
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mortar
3
3
  version: !ruby/object:Gem::Version
4
- hash: 23
4
+ hash: 21
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
8
  - 2
9
- - 0
10
- version: 0.2.0
9
+ - 1
10
+ version: 0.2.1
11
11
  platform: ruby
12
12
  authors:
13
13
  - Mortar Data