gooddata 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +22 -0
- data/README.rdoc +97 -0
- data/VERSION +1 -0
- data/bin/gooddata +13 -0
- data/bin/igd.rb +33 -0
- data/lib/gooddata.rb +3 -0
- data/lib/gooddata/client.rb +196 -0
- data/lib/gooddata/command.rb +75 -0
- data/lib/gooddata/commands/api.rb +37 -0
- data/lib/gooddata/commands/auth.rb +74 -0
- data/lib/gooddata/commands/base.rb +80 -0
- data/lib/gooddata/commands/datasets.rb +228 -0
- data/lib/gooddata/commands/help.rb +104 -0
- data/lib/gooddata/commands/profile.rb +9 -0
- data/lib/gooddata/commands/projects.rb +51 -0
- data/lib/gooddata/commands/version.rb +7 -0
- data/lib/gooddata/connection.rb +220 -0
- data/lib/gooddata/extract.rb +13 -0
- data/lib/gooddata/helpers.rb +18 -0
- data/lib/gooddata/model.rb +558 -0
- data/lib/gooddata/models/links.rb +42 -0
- data/lib/gooddata/models/metadata.rb +82 -0
- data/lib/gooddata/models/profile.rb +32 -0
- data/lib/gooddata/models/project.rb +136 -0
- data/lib/gooddata/version.rb +3 -0
- data/test/helper.rb +10 -0
- data/test/test_commands.rb +85 -0
- data/test/test_guessing.rb +46 -0
- data/test/test_model.rb +63 -0
- data/test/test_rest_api_basic.rb +41 -0
- data/test/test_upload.rb +52 -0
- metadata +202 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
module GoodData::Command
|
2
|
+
class Api < Base
|
3
|
+
def info
|
4
|
+
json = gooddata.release_info
|
5
|
+
puts "GoodData API"
|
6
|
+
puts " Version: #{json['releaseName']}"
|
7
|
+
puts " Released: #{json['releaseDate']}"
|
8
|
+
puts " For more info see #{json['releaseNotesUri']}"
|
9
|
+
end
|
10
|
+
alias :index :info
|
11
|
+
|
12
|
+
def test
|
13
|
+
connect
|
14
|
+
if GoodData.test_login
|
15
|
+
puts "Succesfully logged in as #{GoodData.profile.user}"
|
16
|
+
else
|
17
|
+
puts "Unable to log in to GoodData server!"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def get
|
22
|
+
path = args.shift rescue nil
|
23
|
+
raise(CommandFailed, "Specify the path you want to GET.") if path.nil?
|
24
|
+
connect
|
25
|
+
result = GoodData.get path
|
26
|
+
jj result rescue puts result
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete
|
30
|
+
path = args.shift rescue nil
|
31
|
+
raise(CommandFailed, "Specify the path you want to DELETE.") if path.nil?
|
32
|
+
connect
|
33
|
+
result = GoodData.delete path
|
34
|
+
jj result rescue puts result
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module GoodData::Command
|
2
|
+
class Auth < Base
|
3
|
+
def connect
|
4
|
+
unless defined? @connected
|
5
|
+
GoodData.connect user, password, url
|
6
|
+
@connected = true
|
7
|
+
end
|
8
|
+
@connected
|
9
|
+
end
|
10
|
+
|
11
|
+
def user
|
12
|
+
ensure_credentials
|
13
|
+
@credentials[:username]
|
14
|
+
end
|
15
|
+
|
16
|
+
def password
|
17
|
+
ensure_credentials
|
18
|
+
@credentials[:password]
|
19
|
+
end
|
20
|
+
|
21
|
+
def url
|
22
|
+
ensure_credentials
|
23
|
+
@credentials[:url]
|
24
|
+
end
|
25
|
+
|
26
|
+
def credentials_file
|
27
|
+
"#{home_directory}/.gooddata"
|
28
|
+
end
|
29
|
+
|
30
|
+
def ensure_credentials
|
31
|
+
return if defined? @credentials
|
32
|
+
unless @credentials = read_credentials
|
33
|
+
@credentials = ask_for_credentials
|
34
|
+
end
|
35
|
+
@credentials
|
36
|
+
end
|
37
|
+
|
38
|
+
def read_credentials
|
39
|
+
if File.exists?(credentials_file) then
|
40
|
+
config = File.read(credentials_file)
|
41
|
+
JSON.parser.new(config, :symbolize_names => true).parse
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def ask_for_credentials
|
46
|
+
puts "Enter your GoodData credentials."
|
47
|
+
user = ask("Email")
|
48
|
+
password = ask("Password", :secret => true)
|
49
|
+
{ :username => user, :password => password }
|
50
|
+
end
|
51
|
+
|
52
|
+
def store
|
53
|
+
credentials = ask_for_credentials
|
54
|
+
|
55
|
+
ovewrite = if File.exist?(credentials_file)
|
56
|
+
ask "Overwrite existing stored credentials", :answers => %w(y n)
|
57
|
+
else
|
58
|
+
'y'
|
59
|
+
end
|
60
|
+
|
61
|
+
if ovewrite == 'y'
|
62
|
+
File.open(credentials_file, 'w', 0600) do |f|
|
63
|
+
f.puts JSON.pretty_generate(credentials)
|
64
|
+
end
|
65
|
+
else
|
66
|
+
puts 'Aborting...'
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def unstore
|
71
|
+
FileUtils.rm_f(credentials_file)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module GoodData
|
2
|
+
module Command
|
3
|
+
|
4
|
+
# Initializes GoodData connection with credentials loaded from
|
5
|
+
# ~/.gooddata. If the file doesn't exist or doesn't contain
|
6
|
+
# necessary information, a command line prompt will be issued
|
7
|
+
# using the GoodData::Command::Base#ask method
|
8
|
+
#
|
9
|
+
def self.connect
|
10
|
+
run_internal('auth:connect', [])
|
11
|
+
end
|
12
|
+
|
13
|
+
class Base
|
14
|
+
include GoodData::Helpers
|
15
|
+
|
16
|
+
attr_accessor :args
|
17
|
+
|
18
|
+
def initialize(args)
|
19
|
+
@args = args
|
20
|
+
end
|
21
|
+
|
22
|
+
def connect
|
23
|
+
@connected ||= Command.connect
|
24
|
+
GoodData.connection
|
25
|
+
end
|
26
|
+
|
27
|
+
def extract_option(options, default=true)
|
28
|
+
values = options.is_a?(Array) ? options : [options]
|
29
|
+
return unless opt_index = args.select { |a| values.include? a }.first
|
30
|
+
opt_position = args.index(opt_index) + 1
|
31
|
+
if args.size > opt_position && opt_value = args[opt_position]
|
32
|
+
if opt_value.include?('--')
|
33
|
+
opt_value = nil
|
34
|
+
else
|
35
|
+
args.delete_at(opt_position)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
opt_value ||= default
|
39
|
+
args.delete(opt_index)
|
40
|
+
block_given? ? yield(opt_value) : opt_value
|
41
|
+
end
|
42
|
+
|
43
|
+
def ask(question, options = {})
|
44
|
+
begin
|
45
|
+
if options.has_key? :answers
|
46
|
+
answer = nil
|
47
|
+
while !options[:answers].include?(answer)
|
48
|
+
answer = get_answer "#{question} [#{options[:answers].join(',')}]? ", options[:secret]
|
49
|
+
end
|
50
|
+
else
|
51
|
+
question = "#{question} [#{options[:default]}]" if options[:default]
|
52
|
+
answer = get_answer "#{question}: ", options[:secret], options[:default]
|
53
|
+
end
|
54
|
+
puts if options[:secret] # extra line-break
|
55
|
+
rescue NoMethodError, Interrupt => e
|
56
|
+
system "stty echo"
|
57
|
+
puts e
|
58
|
+
exit
|
59
|
+
end
|
60
|
+
|
61
|
+
if block_given?
|
62
|
+
yield answer
|
63
|
+
else
|
64
|
+
return answer
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def get_answer(question, secret, default = nil)
|
71
|
+
print question
|
72
|
+
system "stty -echo" if secret
|
73
|
+
answer = $stdin.gets.chomp
|
74
|
+
system "stty echo" if secret
|
75
|
+
answer = default if answer.empty? && default
|
76
|
+
answer
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,228 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'gooddata/extract'
|
3
|
+
|
4
|
+
module GoodData
|
5
|
+
module Command
|
6
|
+
class Datasets < Base
|
7
|
+
|
8
|
+
# List all data sets present in the project specified by the --project option
|
9
|
+
#
|
10
|
+
# == Usage
|
11
|
+
#
|
12
|
+
# <tt>gooddata datasets --project <projectid></tt>
|
13
|
+
# <tt>gooddata datasets:list --project <projectid></tt>
|
14
|
+
#
|
15
|
+
# * <tt>--project</tt> - GoodData project identifier
|
16
|
+
#
|
17
|
+
def index
|
18
|
+
connect
|
19
|
+
with_project do |project_id|
|
20
|
+
Project[project_id].datasets.each do |ds|
|
21
|
+
puts "#{ds.uri}\t#{ds.identifier}\t#{ds.title}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Describe a data set. Currently, only a CSV data set is supported.
|
27
|
+
#
|
28
|
+
# The command prescans the data set, picks possible LDM types for it's
|
29
|
+
# fields and asks user for confirmation.
|
30
|
+
#
|
31
|
+
# == Usage
|
32
|
+
#
|
33
|
+
# <tt>gooddata datasets:describe --file-csv <path> --name <name> --output <output path></tt>
|
34
|
+
#
|
35
|
+
# * <tt>--file-csv</tt> - path to the CSV file (required)
|
36
|
+
# * <tt>--name</tt> - name of the data set (user will be prompted unless provided)
|
37
|
+
# * <tt>--output</tt> - name of the output JSON file with the model description (user will be prompted unless provided)
|
38
|
+
#
|
39
|
+
def describe
|
40
|
+
columns = ask_for_fields
|
41
|
+
name = extract_option('--name') || ask("Enter the dataset name")
|
42
|
+
output = extract_option('--output') || ask("Enter path to the file where to save the model description", :default => "#{name}.json")
|
43
|
+
open output, 'w' do |f|
|
44
|
+
f << JSON.pretty_generate( :title => name, :columns => columns ) + "\n"
|
45
|
+
f.flush
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Creates a server-side model based on local model description. The model description
|
50
|
+
# is read from a JSON file that can be generated using the +datasets:describe+ command
|
51
|
+
#
|
52
|
+
# == Usage
|
53
|
+
#
|
54
|
+
# <tt>gooddata datasets:apply --project <projectid> <data set config></tt>
|
55
|
+
#
|
56
|
+
# * <tt>--project</tt> - GoodData project identifier
|
57
|
+
# * <tt>data set config</tt> - JSON file with the model description (possibly generated by the <tt>datasets:describe</tt> command)
|
58
|
+
#
|
59
|
+
def apply
|
60
|
+
connect
|
61
|
+
with_project do |project_id|
|
62
|
+
cfg_file = args.shift rescue nil
|
63
|
+
raise(CommandFailed, "Usage: #{$0} <dataset config>") unless cfg_file
|
64
|
+
config = JSON.load open(cfg_file) rescue raise(CommandFailed, "Error reading dataset config file '#{cfg_file}'")
|
65
|
+
objects = Project[project_id].add_dataset config['title'], config['columns']
|
66
|
+
puts "Dataset #{config['title']} added to the project, #{objects['uris'].length} metadata objects affected"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Load a CSV file into an existing server-side data set
|
71
|
+
#
|
72
|
+
# == Usage
|
73
|
+
#
|
74
|
+
# <tt>gooddata datasets:load --project <projectid> <file> <dataset config></tt>
|
75
|
+
#
|
76
|
+
# * <tt>--project</tt> - GoodData project identifier
|
77
|
+
# * <tt>file</tt> - CSV file to load
|
78
|
+
# * <tt>data set config</tt> - JSON file with the model description (possibly generated by the <tt>datasets:describe</tt> command)
|
79
|
+
#
|
80
|
+
def load
|
81
|
+
connect
|
82
|
+
with_project do |project_id|
|
83
|
+
file, cfg_file = args
|
84
|
+
raise(CommandFailed, "Usage: #{$0} datasets:load <file> <dataset config>") unless cfg_file
|
85
|
+
config = JSON.load open(cfg_file) rescue raise(CommandFailed, "Error reading dataset config file '#{cfg_file}'")
|
86
|
+
schema = Model::Schema.new config
|
87
|
+
Project[project_id].upload file, schema
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def with_project
|
94
|
+
unless @project_id
|
95
|
+
@project_id = extract_option('--project')
|
96
|
+
raise CommandFailed.new("Project not specified, use the --project switch") unless @project_id
|
97
|
+
end
|
98
|
+
yield @project_id
|
99
|
+
end
|
100
|
+
|
101
|
+
def ask_for_fields
|
102
|
+
guesser = Guesser.new create_dataset.read
|
103
|
+
guess = guesser.guess(1000)
|
104
|
+
model = []
|
105
|
+
connection_point_set = false
|
106
|
+
question_fmt = 'Select data type of column #%i (%s)'
|
107
|
+
guesser.headers.each_with_index do |header, i|
|
108
|
+
options = guess[header].map { |t| t.to_s }
|
109
|
+
options = options.select { |t| t != :connection_point.to_s } if connection_point_set
|
110
|
+
type = ask question_fmt % [ i + 1, header ], :answers => options
|
111
|
+
model.push :title => header, :name => header, :type => type.upcase
|
112
|
+
connection_point_set = true if type == :connection_point.to_s
|
113
|
+
end
|
114
|
+
model
|
115
|
+
end
|
116
|
+
|
117
|
+
def create_dataset
|
118
|
+
file = extract_option('--file-csv')
|
119
|
+
return Extract::CsvFile.new(file) if file
|
120
|
+
raise CommandFailed.new("Unknown data set. Please specify a data set using --file-csv option (more supported data sources to come!)")
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
##
|
125
|
+
# Utility class to guess data types of a data stream by looking at first couple of rows
|
126
|
+
#
|
127
|
+
class Guesser
|
128
|
+
|
129
|
+
TYPES_PRIORITY = [ :connection_point, :fact, :date, :attribute ]
|
130
|
+
attr_reader :headers
|
131
|
+
|
132
|
+
class << self
|
133
|
+
def sort_types(types)
|
134
|
+
types.sort do |x, y|
|
135
|
+
TYPES_PRIORITY.index(x) <=> TYPES_PRIORITY.index(y)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def initialize(reader)
|
141
|
+
@reader = reader
|
142
|
+
@headers = reader.shift.map! { |h| h.to_s } or raise "Empty data set"
|
143
|
+
@pros = {}; @cons = {}; @seen = {}
|
144
|
+
@headers.map do |h|
|
145
|
+
@cons[h.to_s] = {}
|
146
|
+
@pros[h.to_s] = {}
|
147
|
+
@seen[h.to_s] = {}
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def guess(limit)
|
152
|
+
count = 0
|
153
|
+
while row = @reader.shift
|
154
|
+
break unless row && !row.empty? && count < limit
|
155
|
+
raise "%i fields in row %i, %i expected" % [ row.size, count + 1, @headers.size ] if row.size != @headers.size
|
156
|
+
row.each_with_index do |value, j|
|
157
|
+
header = @headers[j]
|
158
|
+
number = check_number(header, value)
|
159
|
+
date = check_date(header, value)
|
160
|
+
store_guess header, { @pros => :attribute } unless number || date
|
161
|
+
hash_increment @seen[header], value
|
162
|
+
end
|
163
|
+
count += 1
|
164
|
+
end
|
165
|
+
# fields with unique values are connection point candidates
|
166
|
+
@seen.each do |header, values|
|
167
|
+
store_guess header, { @pros => :connection_point } if values.size == count
|
168
|
+
end
|
169
|
+
guess_result
|
170
|
+
end
|
171
|
+
|
172
|
+
private
|
173
|
+
|
174
|
+
def guess_result
|
175
|
+
result = {}
|
176
|
+
@headers.each do |header|
|
177
|
+
result[header] = Guesser::sort_types @pros[header].keys.select { |type| @cons[header][type].nil? }
|
178
|
+
end
|
179
|
+
result
|
180
|
+
end
|
181
|
+
|
182
|
+
def hash_increment(hash, key)
|
183
|
+
if hash[key]
|
184
|
+
hash[key] += 1
|
185
|
+
else
|
186
|
+
hash[key] = 1
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def check_number(header, value)
|
191
|
+
if value.nil? || value =~ /^[\+-]?\d*(\.\d*)?$/
|
192
|
+
return store_guess(header, @pros => [ :fact, :attribute ] )
|
193
|
+
end
|
194
|
+
store_guess header, { @cons => :fact }
|
195
|
+
end
|
196
|
+
|
197
|
+
def check_date(header, value)
|
198
|
+
return store_guess(header, @pros => [ :date, :attribute, :fact ]) if value.nil? || value == '0000-00-00'
|
199
|
+
begin
|
200
|
+
DateTime.parse value
|
201
|
+
return store_guess(header, @pros => [ :date, :attribute ])
|
202
|
+
rescue ArgumentError; end
|
203
|
+
store_guess header, { @cons => :date }
|
204
|
+
end
|
205
|
+
|
206
|
+
##
|
207
|
+
# Stores a guess about given header.
|
208
|
+
#
|
209
|
+
# Returns true if the @pros key is present, false otherwise
|
210
|
+
#
|
211
|
+
# === Parameters
|
212
|
+
#
|
213
|
+
# * +header+ - A header name
|
214
|
+
# * +guess+ - A hash with optional @pros and @cons keys
|
215
|
+
#
|
216
|
+
def store_guess(header, guess)
|
217
|
+
result = !guess[@pros].nil?
|
218
|
+
[@pros, @cons].each do |hash|
|
219
|
+
if guess[hash] then
|
220
|
+
guess[hash] = [ guess[hash] ] unless guess[hash].is_a? Array
|
221
|
+
guess[hash].each { |type| hash_increment hash[header], type }
|
222
|
+
end
|
223
|
+
end
|
224
|
+
result
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module GoodData
|
2
|
+
module Command
|
3
|
+
class Help < Base
|
4
|
+
class HelpGroup < Array
|
5
|
+
attr_reader :title
|
6
|
+
|
7
|
+
def initialize(title)
|
8
|
+
@title = title
|
9
|
+
end
|
10
|
+
|
11
|
+
def command(name, description)
|
12
|
+
self << [name, description]
|
13
|
+
end
|
14
|
+
|
15
|
+
def space
|
16
|
+
self << ['', '']
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class << self
|
21
|
+
def groups
|
22
|
+
@groups ||= []
|
23
|
+
end
|
24
|
+
|
25
|
+
def group(title, &block)
|
26
|
+
groups << begin
|
27
|
+
group = HelpGroup.new(title)
|
28
|
+
yield group
|
29
|
+
group
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_default_groups!
|
34
|
+
group 'General Commands' do |group|
|
35
|
+
group.command 'help', 'show this usage'
|
36
|
+
group.command 'version', 'show the gem version'
|
37
|
+
group.space
|
38
|
+
group.command 'api', 'general info about the current GoodData API version'
|
39
|
+
group.command 'api:test', 'test you credentials and the connection to GoodData server'
|
40
|
+
group.command 'api:get', 'issue a generic GET request to the GoodData API'
|
41
|
+
group.space
|
42
|
+
group.command 'auth:store', 'save your GoodData credentials and we won\'t ask you for them ever again'
|
43
|
+
group.command 'auth:unstore', 'remove the saved GoodData credentials from your computer'
|
44
|
+
group.space
|
45
|
+
group.command 'datasets', 'list remote data sets in the project specified via --project'
|
46
|
+
group.command 'datasets:describe', 'describe a local data set and save the description in a JSON file'
|
47
|
+
group.space
|
48
|
+
group.command 'profile', 'show your GoodData profile'
|
49
|
+
group.space
|
50
|
+
group.command 'projects', 'list available projects'
|
51
|
+
group.command 'projects:create', 'create new project'
|
52
|
+
group.command 'projects:show <key>', 'show project details'
|
53
|
+
group.command 'projects:delete <key> [...]', 'delete one or more existing projects'
|
54
|
+
end
|
55
|
+
|
56
|
+
group 'General Options' do |group|
|
57
|
+
group.command '--log-level <level>', 'Set the log level (fatal, error, warn [default], info, debug)'
|
58
|
+
group.command '--project <project_id>', 'Set the working remote project identified by an URI or project ID'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def index
|
64
|
+
puts usage
|
65
|
+
end
|
66
|
+
|
67
|
+
def version
|
68
|
+
puts Client.version
|
69
|
+
end
|
70
|
+
|
71
|
+
def usage
|
72
|
+
longest_command_length = self.class.groups.map do |group|
|
73
|
+
group.map { |g| g.first.length }
|
74
|
+
end.flatten.max
|
75
|
+
|
76
|
+
s = StringIO.new
|
77
|
+
s << <<-EOT
|
78
|
+
=== Usage
|
79
|
+
|
80
|
+
gooddata COMMAND [options]
|
81
|
+
|
82
|
+
EOT
|
83
|
+
|
84
|
+
self.class.groups.inject(s) do |output, group|
|
85
|
+
output.puts "=== %s" % group.title
|
86
|
+
output.puts
|
87
|
+
|
88
|
+
group.each do |command, description|
|
89
|
+
if command.empty?
|
90
|
+
output.puts
|
91
|
+
else
|
92
|
+
output.puts "%-*s # %s" % [longest_command_length, command, description]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
output.puts
|
97
|
+
output
|
98
|
+
end.string
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
GoodData::Command::Help.create_default_groups!
|