itunes-connect 0.9.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 +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
|