gooddata 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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!