gooddata 0.2.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/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!
|