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.
@@ -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!