itunes-connect 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.rdoc +86 -0
- data/bin/itunes_connect +27 -0
- data/lib/itunes_connect.rb +5 -0
- data/lib/itunes_connect/commands.rb +41 -0
- data/lib/itunes_connect/commands/download.rb +76 -0
- data/lib/itunes_connect/commands/help.rb +31 -0
- data/lib/itunes_connect/commands/import.rb +35 -0
- data/lib/itunes_connect/commands/report.rb +78 -0
- data/lib/itunes_connect/connection.rb +162 -0
- data/lib/itunes_connect/rc_file.rb +30 -0
- data/lib/itunes_connect/report.rb +56 -0
- data/lib/itunes_connect/store.rb +129 -0
- data/spec/commands/download_spec.rb +140 -0
- data/spec/commands/help_spec.rb +64 -0
- data/spec/commands/import_spec.rb +66 -0
- data/spec/commands/report_spec.rb +195 -0
- data/spec/commands_spec.rb +47 -0
- data/spec/connection_spec.rb +26 -0
- data/spec/fakeweb/homepage +365 -0
- data/spec/fixtures/report.txt +5 -0
- data/spec/report_spec.rb +37 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/store_spec.rb +142 -0
- metadata +146 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
require "yaml"
|
2
|
+
|
3
|
+
class ItunesConnect::RcFile # :nodoc:
|
4
|
+
|
5
|
+
DEFAULT_RCFILE_PATH = File.expand_path("~/.itunesrc")
|
6
|
+
|
7
|
+
def self.default
|
8
|
+
self.new(DEFAULT_RCFILE_PATH)
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(path=DEFAULT_RCFILE_PATH)
|
12
|
+
if File.exist?(path)
|
13
|
+
@rc = YAML.load_file(path)
|
14
|
+
else
|
15
|
+
@rc = { }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def username
|
20
|
+
@rc[:username]
|
21
|
+
end
|
22
|
+
|
23
|
+
def password
|
24
|
+
@rc[:password]
|
25
|
+
end
|
26
|
+
|
27
|
+
def database
|
28
|
+
@rc[:database]
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require "ostruct"
|
2
|
+
|
3
|
+
# This class transforms the raw input given in the constructor into a
|
4
|
+
# series of objects representing each row. You can either get the
|
5
|
+
# entire set of data by accessing the +data+ attribute, or by calling
|
6
|
+
# the +each+ method and handing it a block.
|
7
|
+
class ItunesConnect::Report
|
8
|
+
include Enumerable
|
9
|
+
|
10
|
+
# The report as a Hash, where the keys are country codes and the
|
11
|
+
# values are Hashes with the keys, <tt>:date</tt>, <tt>:upgrade</tt>,
|
12
|
+
# <tt>:install</tt>.
|
13
|
+
attr_reader :data
|
14
|
+
|
15
|
+
# Give me an +IO+-like object (one that responds to the +each+
|
16
|
+
# method) and I'll parse that sucker for you.
|
17
|
+
def initialize(input)
|
18
|
+
@data = Hash.new { |h,k| h[k] = { }}
|
19
|
+
input.each do |line|
|
20
|
+
line.chomp!
|
21
|
+
next if line =~ /^(Provider|$)/
|
22
|
+
tokens = line.split(/\s+/)
|
23
|
+
country = tokens[11]
|
24
|
+
count = tokens[6].to_i
|
25
|
+
@data[country][:date] = Date.parse(tokens[8])
|
26
|
+
case tokens[5].to_i
|
27
|
+
when 7
|
28
|
+
@data[country][:upgrade] = count
|
29
|
+
when 1
|
30
|
+
@data[country][:install] = count
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Yields each parsed data row to the given block. Each item yielded
|
36
|
+
# has the following attributes:
|
37
|
+
# * country
|
38
|
+
# * date
|
39
|
+
# * install_count
|
40
|
+
# * upgrade_count
|
41
|
+
def each # :yields: record
|
42
|
+
@data.each do |country, value|
|
43
|
+
if block_given?
|
44
|
+
yield OpenStruct.new(:country => country,
|
45
|
+
:date => value[:date],
|
46
|
+
:install_count => value[:install] || 0,
|
47
|
+
:upgrade_count => value[:upgrade] || 0)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# The total number of rows in the report
|
53
|
+
def size
|
54
|
+
@data.size
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
require "sqlite3"
|
2
|
+
require "ostruct"
|
3
|
+
|
4
|
+
module ItunesConnect
|
5
|
+
# Represents a database stored on disk.
|
6
|
+
class Store
|
7
|
+
# Creates a new instance. If no database file exists at the given
|
8
|
+
# path a new one is created and the correct tables and indexes are
|
9
|
+
# added.
|
10
|
+
def initialize(file, verbose=false)
|
11
|
+
@db = SQLite3::Database.new(file)
|
12
|
+
if @db.table_info("reports").empty?
|
13
|
+
@db.execute("CREATE TABLE reports (id INTEGER PRIMARY KEY, " +
|
14
|
+
"report_date DATE, country TEXT, install_count INTEGER, " +
|
15
|
+
"update_count INTEGER)")
|
16
|
+
@db.execute("CREATE UNIQUE INDEX u_reports_idx ON reports " +
|
17
|
+
"(report_date, country)")
|
18
|
+
end
|
19
|
+
@verbose = verbose
|
20
|
+
end
|
21
|
+
|
22
|
+
def verbose? # :nodoc:
|
23
|
+
!!@verbose
|
24
|
+
end
|
25
|
+
|
26
|
+
# Add a record to this instance
|
27
|
+
def add(date, country, install_count, update_count)
|
28
|
+
@db.execute("INSERT INTO reports (report_date, country, " +
|
29
|
+
"install_count, update_count) VALUES (?, ?, ?, ?)",
|
30
|
+
date, country, install_count, update_count)
|
31
|
+
true
|
32
|
+
rescue SQLite3::SQLException => e
|
33
|
+
if e.message =~ /columns .* are not unique/
|
34
|
+
$stdout.puts "Skipping existing row for #{country} on #{date}" if verbose?
|
35
|
+
false
|
36
|
+
else
|
37
|
+
raise e
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
VALID_COUNT_OPTIONS = [:from, :to, :country]
|
42
|
+
# Get counts optionally constrained by dates and/or country codes.
|
43
|
+
# Available options are:
|
44
|
+
# <tt>:from</tt>:: The from date, defaults to the beginning
|
45
|
+
# <tt>:to</tt>:: The end date, defaults to now
|
46
|
+
# <tt>:country</tt>:: The country code, defaults to <tt>nil</tt>
|
47
|
+
# which means no country code restriction
|
48
|
+
def counts(opts={ })
|
49
|
+
unless (leftovers = opts.keys - VALID_COUNT_OPTIONS).empty?
|
50
|
+
raise "Invalid keys: #{leftovers.join(', ')}"
|
51
|
+
end
|
52
|
+
|
53
|
+
params = []
|
54
|
+
clauses = []
|
55
|
+
sql = "SELECT * FROM reports"
|
56
|
+
|
57
|
+
if opts[:from]
|
58
|
+
clauses << "report_date >= ?"
|
59
|
+
params << opts[:from]
|
60
|
+
end
|
61
|
+
|
62
|
+
if opts[:to]
|
63
|
+
clauses << "report_date <= ?"
|
64
|
+
params << opts[:to]
|
65
|
+
end
|
66
|
+
|
67
|
+
if opts[:country]
|
68
|
+
clauses << "country = ?"
|
69
|
+
params << opts[:country]
|
70
|
+
end
|
71
|
+
|
72
|
+
sql << " WHERE " unless clauses.empty?
|
73
|
+
sql << clauses.join(" AND ") unless params.empty?
|
74
|
+
sql << " ORDER BY report_date DESC"
|
75
|
+
|
76
|
+
@db.execute(sql, *params).map do |row|
|
77
|
+
OpenStruct.new({
|
78
|
+
:report_date => Date.parse(row[1]),
|
79
|
+
:country => row[2],
|
80
|
+
:install_count => row[3].to_i,
|
81
|
+
:update_count => row[4].to_i
|
82
|
+
})
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Get summed counts by country, optionally constrained by dates
|
87
|
+
# and/or country codes. Available options are:
|
88
|
+
# <tt>:from</tt>:: The from date, defaults to the beginning
|
89
|
+
# <tt>:to</tt>:: The end date, defaults to now
|
90
|
+
# <tt>:country</tt>:: The country code, defaults to <tt>nil</tt>
|
91
|
+
# which means no country code restriction
|
92
|
+
def country_counts(opts={ })
|
93
|
+
unless (leftovers = opts.keys - VALID_COUNT_OPTIONS).empty?
|
94
|
+
raise "Invalid keys: #{leftovers.join(', ')}"
|
95
|
+
end
|
96
|
+
|
97
|
+
params = []
|
98
|
+
clauses = []
|
99
|
+
sql = "SELECT country, SUM(install_count), SUM(update_count) FROM reports"
|
100
|
+
|
101
|
+
if opts[:from]
|
102
|
+
clauses << "report_date >= ?"
|
103
|
+
params << opts[:from]
|
104
|
+
end
|
105
|
+
|
106
|
+
if opts[:to]
|
107
|
+
clauses << "report_date <= ?"
|
108
|
+
params << opts[:to]
|
109
|
+
end
|
110
|
+
|
111
|
+
if opts[:country]
|
112
|
+
clauses << "country = ?"
|
113
|
+
params << opts[:country]
|
114
|
+
end
|
115
|
+
|
116
|
+
sql << " WHERE " unless clauses.empty?
|
117
|
+
sql << clauses.join(" AND ") unless params.empty?
|
118
|
+
sql << " GROUP BY country ORDER BY country"
|
119
|
+
|
120
|
+
@db.execute(sql, *params).map do |row|
|
121
|
+
OpenStruct.new({
|
122
|
+
:country => row[0],
|
123
|
+
:install_count => row[1].to_i,
|
124
|
+
:update_count => row[2].to_i
|
125
|
+
})
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe ItunesConnect::Commands::Download do
|
4
|
+
before(:each) do
|
5
|
+
@cmd = ItunesConnect::Commands::Download.new(mock(:null_object => true),
|
6
|
+
mock(:username => nil,
|
7
|
+
:password => nil,
|
8
|
+
:database => nil))
|
9
|
+
@defaults = {
|
10
|
+
:username => 'dudeman',
|
11
|
+
:password => 'sekret',
|
12
|
+
:date => nil,
|
13
|
+
:out => nil,
|
14
|
+
:db => nil,
|
15
|
+
:verbose? => false,
|
16
|
+
:debug? => false,
|
17
|
+
:report => 'Daily'
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
describe 'with valid execution arguments' do
|
22
|
+
before(:each) do
|
23
|
+
@connection = mock(ItunesConnect::Connection)
|
24
|
+
ItunesConnect::Connection.should_receive(:new).
|
25
|
+
with('dudeman', 'sekret', false, false).
|
26
|
+
and_return(@connection)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'should call get_report correctly with no args' do
|
30
|
+
@connection.should_receive(:get_report).with(Date.today - 1, $stdout, 'Daily')
|
31
|
+
opts = stub(@defaults)
|
32
|
+
@cmd.execute!(opts)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'should call get_report with date argument when given' do
|
36
|
+
today = Date.today - 15
|
37
|
+
@connection.should_receive(:get_report).with(today, $stdout, 'Daily')
|
38
|
+
opts = stub(@defaults.merge(:date => today))
|
39
|
+
@cmd.execute!(opts)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'should call get_report with File object when path is given' do
|
43
|
+
@connection.should_receive(:get_report).with(Date.today - 1,
|
44
|
+
an_instance_of(File),
|
45
|
+
'Daily')
|
46
|
+
opts = stub(@defaults.merge(:out => '/tmp/foobar'))
|
47
|
+
@cmd.execute!(opts)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'should use the given report type' do
|
51
|
+
@connection.should_receive(:get_report).with(Date.today - 1, $stdout, 'Weekly')
|
52
|
+
opts = stub(@defaults.merge({ :report => 'Weekly' }))
|
53
|
+
@cmd.execute!(opts)
|
54
|
+
end
|
55
|
+
|
56
|
+
describe 'and the :db option is specified' do
|
57
|
+
it 'should import the results into the DB' do
|
58
|
+
t = Date.parse('8/31/2009')
|
59
|
+
@connection.should_receive(:get_report) do |date, io, report|
|
60
|
+
io << read_fixture('fixtures/report.txt')
|
61
|
+
io.flush
|
62
|
+
end
|
63
|
+
|
64
|
+
store = mock(ItunesConnect::Store)
|
65
|
+
store.should_receive(:add).with(t, 'GB', 0, 1)
|
66
|
+
store.should_receive(:add).with(t, 'AR', 0, 1)
|
67
|
+
store.should_receive(:add).with(t, 'US', 1, 3)
|
68
|
+
ItunesConnect::Store.should_receive(:new).
|
69
|
+
with('/tmp/foobar.db', false).
|
70
|
+
and_return(store)
|
71
|
+
|
72
|
+
opts = stub(@defaults.merge(:db => '/tmp/foobar.db',
|
73
|
+
:date => '2009/08/31'))
|
74
|
+
@cmd.execute!(opts)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
describe 'checking execution arguments' do
|
80
|
+
it 'should get grumpy when no username or password is given' do
|
81
|
+
lambda { @cmd.execute! }.should raise_error(ArgumentError)
|
82
|
+
lambda { @cmd.execute!(stub(@defaults.merge(:password => nil))) }.
|
83
|
+
should raise_error(ArgumentError)
|
84
|
+
|
85
|
+
lambda { @cmd.execute!(stub(@defaults.merge(:username => nil))) }.
|
86
|
+
should raise_error(ArgumentError)
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'should reject getting both :out and :db options' do
|
90
|
+
lambda do
|
91
|
+
opts = stub(@defaults.merge(:db => '/tmp/foobar.db',
|
92
|
+
:out => '/tmp/foobar.txt'))
|
93
|
+
@cmd.execute!(opts)
|
94
|
+
end.should raise_error(ArgumentError)
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'should reject invalid report types' do
|
98
|
+
lambda do
|
99
|
+
opts = stub(@defaults.merge(:report => 'Glowing'))
|
100
|
+
@cmd.execute!(opts)
|
101
|
+
end.should raise_error(ArgumentError)
|
102
|
+
end
|
103
|
+
|
104
|
+
it 'should reject requests to store monthly reports in the database' do
|
105
|
+
lambda do
|
106
|
+
opts = stub(@defaults.merge(:report => 'Monthly', :db => '/tmp/foo.db'))
|
107
|
+
@cmd.execute!(opts)
|
108
|
+
end.should raise_error(ArgumentError)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
describe 'setting up command-line options' do
|
113
|
+
it 'should add appropriate options to given Clip' do
|
114
|
+
clip = mock('Clip')
|
115
|
+
clip.should_receive(:opt).
|
116
|
+
with('u', 'username',
|
117
|
+
:desc => 'iTunes Connect username')
|
118
|
+
clip.should_receive(:opt).
|
119
|
+
with('p', 'password',
|
120
|
+
:desc => 'iTunes Connect password')
|
121
|
+
clip.should_receive(:opt).
|
122
|
+
with('d', 'date',
|
123
|
+
:desc => 'Daily report date (MM/DD/YYYY format)',
|
124
|
+
:default => (Date.today - 1).strftime('%m/%d/%Y'))
|
125
|
+
clip.should_receive(:opt).
|
126
|
+
with('o', 'out',
|
127
|
+
:desc => 'Dump report to file, - is stdout')
|
128
|
+
clip.should_receive(:opt).
|
129
|
+
with('b', 'db',
|
130
|
+
:desc => 'Dump report to sqlite DB at the given path')
|
131
|
+
clip.should_receive(:opt).
|
132
|
+
with('r', 'report',
|
133
|
+
:desc => 'Report type. One of "Daily", "Weekly", "Monthly"',
|
134
|
+
:default => 'Daily')
|
135
|
+
|
136
|
+
ItunesConnect::Commands::Download.new(clip)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
require "clip"
|
3
|
+
|
4
|
+
describe ItunesConnect::Commands::Help do
|
5
|
+
before(:each) do
|
6
|
+
@cmd = ItunesConnect::Commands::Help.new(mock(:null_object => true))
|
7
|
+
end
|
8
|
+
|
9
|
+
describe 'with valid execution arguments' do
|
10
|
+
before(:each) do
|
11
|
+
@io = StringIO.new
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'should dump the "download" command usage to :out' do
|
15
|
+
@cmd.execute!(nil, %w(download), @io)
|
16
|
+
clip = ItunesConnect::Commands.default_clip
|
17
|
+
clip.banner = "Command options for 'download':"
|
18
|
+
ItunesConnect::Commands::Download.new(clip)
|
19
|
+
@io.string.should == clip.help
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'should dump the "import" command usage to :out' do
|
23
|
+
@cmd.execute!(nil, %w(import), @io)
|
24
|
+
clip = ItunesConnect::Commands.default_clip
|
25
|
+
clip.banner = "Command options for 'import':"
|
26
|
+
ItunesConnect::Commands::Import.new(clip)
|
27
|
+
@io.string.should == clip.help
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'should dump the "report" command usage to :out' do
|
31
|
+
@cmd.execute!(nil, %w(report), @io)
|
32
|
+
clip = ItunesConnect::Commands.default_clip
|
33
|
+
clip.banner = "Command options for 'report':"
|
34
|
+
ItunesConnect::Commands::Report.new(clip)
|
35
|
+
@io.string.should == clip.help
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe 'when no command name is given' do
|
40
|
+
it 'should raise an ArgumentError' do
|
41
|
+
io = StringIO.new
|
42
|
+
@cmd.execute!(nil, [], io)
|
43
|
+
io.string.should == <<-EOF
|
44
|
+
Available commands:
|
45
|
+
|
46
|
+
download Retrieves reports from the iTunes Connect site
|
47
|
+
import Imports report data into a database file
|
48
|
+
report Generates reports from a local database
|
49
|
+
help Describe a particular command
|
50
|
+
EOF
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe 'when usage is requested for an unrecognized command' do
|
56
|
+
|
57
|
+
it 'should raise an ArgumentError' do
|
58
|
+
lambda { @cmd.execute!({ :out => @io }, %w(bazooka)) }.
|
59
|
+
should raise_error(ArgumentError)
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
require "tempfile"
|
3
|
+
|
4
|
+
describe ItunesConnect::Commands::Import do
|
5
|
+
|
6
|
+
before(:each) do
|
7
|
+
@cmd = ItunesConnect::Commands::Import.new(mock(:null_object => true),
|
8
|
+
mock(:username => nil,
|
9
|
+
:password => nil,
|
10
|
+
:database => nil))
|
11
|
+
end
|
12
|
+
|
13
|
+
describe 'with valid execution arguments' do
|
14
|
+
before(:each) do
|
15
|
+
@store = mock(ItunesConnect::Store)
|
16
|
+
ItunesConnect::Store.should_receive(:new).
|
17
|
+
with("/tmp/store.db", false).
|
18
|
+
and_return(@store)
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'should add a record to the store for each row of data' do
|
22
|
+
t = Date.parse('8/31/2009')
|
23
|
+
@store.should_receive(:add).with(t, 'GB', 0, 1)
|
24
|
+
@store.should_receive(:add).with(t, 'AR', 0, 1)
|
25
|
+
@store.should_receive(:add).with(t, 'US', 1, 3)
|
26
|
+
report_file = File.join(File.dirname(__FILE__), '..', 'fixtures', 'report.txt')
|
27
|
+
@cmd.execute!(stub(:db => '/tmp/store.db',
|
28
|
+
:file => report_file,
|
29
|
+
:verbose? => false))
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe 'execution argument validation' do
|
34
|
+
it 'should reject missing all options' do
|
35
|
+
lambda { @cmd.execute! }.should raise_error(ArgumentError)
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'should reject missing :file option' do
|
39
|
+
lambda do
|
40
|
+
@cmd.execute!(stub(:db => '/tmp/store.db', :file => nil))
|
41
|
+
end.should raise_error(ArgumentError)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'should reject missing :db option' do
|
45
|
+
lambda do
|
46
|
+
@cmd.execute!(stub(:db => nil, :file => '/tmp/report.txt', :verbose? => false))
|
47
|
+
end.should raise_error(ArgumentError)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe 'setting up command-line parsing' do
|
52
|
+
|
53
|
+
it 'should add appropriate options to a given Clip' do
|
54
|
+
clip = mock('Clip')
|
55
|
+
clip.should_receive(:opt).
|
56
|
+
with('b', 'db',
|
57
|
+
:desc => 'Dump report to sqlite DB at the given path')
|
58
|
+
clip.should_receive(:req).
|
59
|
+
with('f', 'file',
|
60
|
+
:desc => 'The file to import, - means standard in')
|
61
|
+
|
62
|
+
ItunesConnect::Commands::Import.new(clip)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|