tweetwine 0.2.5 → 0.2.7

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.
Files changed (41) hide show
  1. data/CHANGELOG.rdoc +30 -17
  2. data/README.rdoc +16 -17
  3. data/Rakefile +2 -0
  4. data/bin/tweetwine +3 -68
  5. data/example/example_helper.rb +31 -41
  6. data/example/fixtures/{statuses.json → home.json} +0 -0
  7. data/example/fixtures/mentions.json +1 -0
  8. data/example/fixtures/search.json +1 -0
  9. data/example/fixtures/update.json +1 -0
  10. data/example/fixtures/user.json +1 -0
  11. data/example/fixtures/users.json +1 -0
  12. data/example/search_statuses_example.rb +36 -0
  13. data/example/show_followers_example.rb +23 -0
  14. data/example/show_friends_example.rb +23 -0
  15. data/example/show_home_example.rb +51 -0
  16. data/example/show_mentions_example.rb +23 -0
  17. data/example/show_metadata_example.rb +54 -7
  18. data/example/show_user_example.rb +37 -0
  19. data/example/update_status_example.rb +65 -0
  20. data/lib/tweetwine/cli.rb +241 -0
  21. data/lib/tweetwine/client.rb +94 -57
  22. data/lib/tweetwine/io.rb +39 -28
  23. data/lib/tweetwine/meta.rb +1 -1
  24. data/lib/tweetwine/retrying_http.rb +93 -0
  25. data/lib/tweetwine/startup_config.rb +14 -15
  26. data/lib/tweetwine/url_shortener.rb +13 -8
  27. data/lib/tweetwine/util.rb +14 -0
  28. data/lib/tweetwine.rb +2 -1
  29. data/test/cli_test.rb +16 -0
  30. data/test/client_test.rb +275 -205
  31. data/test/fixtures/test_config.yaml +2 -1
  32. data/test/io_test.rb +89 -62
  33. data/test/retrying_http_test.rb +127 -0
  34. data/test/startup_config_test.rb +52 -27
  35. data/test/test_helper.rb +32 -17
  36. data/test/url_shortener_test.rb +18 -18
  37. data/test/util_test.rb +145 -47
  38. metadata +20 -7
  39. data/example/show_latest_statuses_example.rb +0 -45
  40. data/lib/tweetwine/rest_client_wrapper.rb +0 -37
  41. data/test/rest_client_wrapper_test.rb +0 -68
@@ -0,0 +1,37 @@
1
+ require "example_helper"
2
+
3
+ FakeWeb.register_uri(:get, "https://#{TEST_AUTH}@twitter.com/statuses/user_timeline/#{TEST_USER}.json?count=20&page=1", :body => fixture("user.json"))
4
+
5
+ Feature "show a specific user's latest statuses" do
6
+ in_order_to "to see what is going on with a specific user"
7
+ as_a "authenticated user"
8
+ i_want_to "see the latest statuses of a specific user"
9
+
10
+ Scenario "see my latest statuses" do
11
+ When "application is launched 'user' command and without extra arguments" do
12
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors user})
13
+ end
14
+
15
+ Then "my the latest statuses are shown" do
16
+ @output[0].should == "jillv, in reply to chris, 9 hours ago:"
17
+ @output[1].should == "@chris wait me until the garden"
18
+ @output[2].should == ""
19
+ @output[3].should == "jillv, 3 days ago:"
20
+ @output[4].should == "so boring to wait"
21
+ end
22
+ end
23
+
24
+ Scenario "see the latest statuses of another user" do
25
+ When "application is launched 'user' command and the user as an extra argument" do
26
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors user #{TEST_USER}})
27
+ end
28
+
29
+ Then "the latest statuses of the user are shown" do
30
+ @output[0].should == "jillv, in reply to chris, 9 hours ago:"
31
+ @output[1].should == "@chris wait me until the garden"
32
+ @output[2].should == ""
33
+ @output[3].should == "jillv, 3 days ago:"
34
+ @output[4].should == "so boring to wait"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,65 @@
1
+ require "example_helper"
2
+
3
+ FakeWeb.register_uri(:post, "https://#{TEST_AUTH}@twitter.com/statuses/update.json", :body => fixture("update.json"))
4
+
5
+ Feature "update my status" do
6
+ in_order_to "tell something about me to the world"
7
+ as_a "authenticated user"
8
+ i_want_to "update my status"
9
+
10
+ STATUS = "bored. going to sleep."
11
+
12
+ Scenario "update my status from command line with colorization disabled" do
13
+ When "application is launched 'update' command" do
14
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors update '#{STATUS}'}, "y")
15
+ end
16
+
17
+ Then "the status sent is shown" do
18
+ @output[5].should == "#{TEST_USER}, 9 hours ago:"
19
+ @output[6].should == "#{STATUS}"
20
+ end
21
+ end
22
+
23
+ Scenario "update my status from command line with colorization enabled" do
24
+ When "application is launched 'update' command" do
25
+ @output = launch_cli(%W{-a #{TEST_AUTH} --colors update '#{STATUS}'}, "y")
26
+ end
27
+
28
+ Then "the status sent is shown" do
29
+ @output[5].should == "\e[32m#{TEST_USER}\e[0m, 9 hours ago:"
30
+ @output[6].should == "#{STATUS}"
31
+ end
32
+ end
33
+
34
+ Scenario "cancel a status from command line" do
35
+ When "application is launched 'update' command" do
36
+ @output = launch_cli(%W{-a #{TEST_AUTH} --colors update '#{STATUS}'}, "n")
37
+ end
38
+
39
+ Then "a cancellation message is shown" do
40
+ @output[3].should =~ /Cancelled./
41
+ end
42
+ end
43
+
44
+ Scenario "update my status from STDIN" do
45
+ When "application is launched 'update' command" do
46
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors update}, STATUS, "y")
47
+ end
48
+
49
+ Then "the status sent is shown" do
50
+ @output[0].should == "Status update: "
51
+ @output[5].should == "#{TEST_USER}, 9 hours ago:"
52
+ @output[6].should == "#{STATUS}"
53
+ end
54
+ end
55
+
56
+ Scenario "cancel a status update from STDIN" do
57
+ When "application is launched 'update' command" do
58
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors update}, STATUS, "n")
59
+ end
60
+
61
+ Then "a cancellation message is shown" do
62
+ @output[3].should =~ /Cancelled./
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,241 @@
1
+ require "optparse"
2
+
3
+ module Tweetwine
4
+ class CLI
5
+ EXIT_HELP = 1
6
+ EXIT_VERSION = 2
7
+ EXIT_ERROR = 255
8
+
9
+ def self.launch(args, exec_name, config_file, extra_opts = {})
10
+ new(args, exec_name, config_file, extra_opts, &default_dependencies).execute(args)
11
+ rescue ArgumentError, HttpError => e
12
+ puts "Error: #{e.message}"
13
+ exit(EXIT_ERROR)
14
+ end
15
+
16
+ def execute(args)
17
+ if @config.command != :help
18
+ cmd_options = parse_command_options(@config.command, args)
19
+ @client.send(@config.command, args, cmd_options)
20
+ else
21
+ show_help_command_and_exit(args)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def self.default_dependencies
28
+ lambda do |options|
29
+ io = Tweetwine::IO.new(options)
30
+ http_client = RetryingHttp::Client.new(io)
31
+ url_shortener = lambda { |opts| UrlShortener.new(http_client, opts) }
32
+ Client::Dependencies.new(io, http_client, url_shortener)
33
+ end
34
+ end
35
+
36
+ def initialize(args, exec_name, config_file, extra_opts = {}, &dependencies_blk)
37
+ @global_option_parser = create_global_option_parser(exec_name)
38
+ @config = StartupConfig.new(Client::COMMANDS + [:help], Client::DEFAULT_COMMAND, extra_opts)
39
+ @config.parse(args, config_file, &@global_option_parser)
40
+ @client = Client.new(dependencies_blk.call(@config.options), @config.options) if @config.command != :help
41
+ end
42
+
43
+ def show_help_command_and_exit(args)
44
+ help_about_cmd = args.shift
45
+ if help_about_cmd
46
+ help_about_cmd = help_about_cmd.to_sym
47
+ parse_command_options(help_about_cmd, ["-h"]) if Client::COMMANDS.include?(help_about_cmd)
48
+ end
49
+ @global_option_parser.call(["-h"])
50
+ end
51
+
52
+ def self.create_option_parser
53
+ lambda do |args|
54
+ parsed_options = {}
55
+ begin
56
+ parser = OptionParser.new do |opt|
57
+ opt.on_tail("-h", "--help", "Show this help message and exit") {
58
+ puts opt
59
+ exit(EXIT_HELP)
60
+ }
61
+ schema = yield parsed_options
62
+ opt.banner = schema[:help]
63
+ schema[:opts].each do |opt_schema|
64
+ opt.on(*option_schema_to_ary(opt_schema), &opt_schema[:action])
65
+ end if schema[:opts]
66
+ end.order!(args)
67
+ rescue OptionParser::ParseError => e
68
+ raise ArgumentError, e.message
69
+ end
70
+ parsed_options
71
+ end
72
+ end
73
+
74
+ def self.option_schema_to_ary(opt_schema)
75
+ [:short, :long, :type, :desc].inject([]) do |result, key|
76
+ result << opt_schema[key] if opt_schema[key]
77
+ result
78
+ end
79
+ end
80
+
81
+ def create_global_option_parser(exec_name)
82
+ self.class.create_option_parser do |parsed|
83
+ {
84
+ :help => \
85
+ "A simple but tasty Twitter agent for command line use, made for fun.
86
+
87
+ Usage: #{exec_name} [global_options...] [command] [command_options...]
88
+
89
+ [command] is one of
90
+ * #{Client::COMMANDS[0...-1].join(",\n * ")}, or
91
+ * #{Client::COMMANDS.last}.
92
+
93
+ The default command is #{Client::DEFAULT_COMMAND}.
94
+
95
+ [global_options]:
96
+ ",
97
+ :opts => [
98
+ {
99
+ :short => "-a",
100
+ :long => "--auth USERNAME:PASSWORD",
101
+ :desc => "Authentication",
102
+ :action => lambda { |arg| parsed[:username], parsed[:password] = arg.split(":", 2) }
103
+ },
104
+ {
105
+ :short => "-c",
106
+ :long => "--colors",
107
+ :desc => "Colorize output with ANSI escape codes",
108
+ :action => lambda { |arg| parsed[:colors] = true }
109
+ },
110
+ {
111
+ :short => "-n",
112
+ :long => "--num N",
113
+ :type => Integer,
114
+ :desc => "The number of statuses in page, default #{Client::DEFAULT_NUM_STATUSES}",
115
+ :action => lambda { |arg| parsed[:num_statuses] = arg }
116
+ },
117
+ {
118
+ :long => "--no-colors",
119
+ :desc => "Do not use ANSI colors",
120
+ :action => lambda { |arg| parsed[:colors] = false }
121
+ },
122
+ {
123
+ :long => "--no-url-shorten",
124
+ :desc => "Do not shorten URLs for status update",
125
+ :action => lambda { |arg| parsed[:shorten_urls] = { :enable => false } }
126
+ },
127
+ {
128
+ :short => "-p",
129
+ :long => "--page N",
130
+ :type => Integer,
131
+ :desc => "The page number for statuses, default #{Client::DEFAULT_PAGE_NUM}",
132
+ :action => lambda { |arg| parsed[:page_num] = arg }
133
+ },
134
+ {
135
+ :short => "-v",
136
+ :long => "--version",
137
+ :desc => "Show version information and exit",
138
+ :action => lambda do |arg|
139
+ puts "#{exec_name} #{Tweetwine::VERSION}"
140
+ exit(EXIT_VERSION)
141
+ end
142
+ }
143
+ ]
144
+ }
145
+ end
146
+ end
147
+
148
+ def self.create_command_option_parser(command_name, schema)
149
+ create_option_parser do |parsed|
150
+ {
151
+ :help => \
152
+ "#{command_name} [command_options...] #{schema[:help][:rest_args]}
153
+
154
+ #{schema[:help][:desc]}
155
+
156
+ [command_options]:
157
+ ",
158
+ :opts => schema[:opts]
159
+ }
160
+ end
161
+ end
162
+
163
+ command_parser_schemas = {
164
+ :followers => {
165
+ :help => {
166
+ :desc => \
167
+ "Show the followers of the authenticated user, together with the latest status
168
+ of each follower."
169
+ }
170
+ },
171
+ :friends => {
172
+ :help => {
173
+ :desc => \
174
+ "Show the friends of the authenticated user, together with the latest status of
175
+ each friend."
176
+ }
177
+ },
178
+ :home => {
179
+ :help => {
180
+ :desc => \
181
+ "Show the latest statuses of friends and own tweets (the home timeline of the
182
+ authenticated user)."
183
+ }
184
+ },
185
+ :mentions => {
186
+ :help => {
187
+ :desc => \
188
+ "Show the latest statuses that mention the authenticated user."
189
+ }
190
+ },
191
+ :search => {
192
+ :help => {
193
+ :rest_args => "word_1 [word_2...]",
194
+ :desc => \
195
+ "Search the latest public statuses with one or more words."
196
+ },
197
+ :opts => [
198
+ {
199
+ :short => "-a",
200
+ :long => "--and",
201
+ :desc => "All words must match",
202
+ :action => lambda { |arg| parsed[:bin_op] = :and }
203
+ },
204
+ {
205
+ :short => "-o",
206
+ :long => "--or",
207
+ :desc => "Any word matches",
208
+ :action => lambda { |arg| parsed[:bin_op] = :or }
209
+ }
210
+ ]
211
+ },
212
+ :update => {
213
+ :help => {
214
+ :rest_args => "[status]",
215
+ :desc => \
216
+ "Send a status update, but confirm the action first before actually sending.
217
+ The status update can either be given as an argument or via STDIN if no
218
+ [status] is given."
219
+ }
220
+ },
221
+ :user => {
222
+ :help => {
223
+ :rest_args => "[username]",
224
+ :desc => \
225
+ "Show a specific user's latest statuses. The user is identified with [username]
226
+ argument; if the argument is absent, the authenticated user's statuses are
227
+ shown."
228
+ }
229
+ }
230
+ }
231
+
232
+ COMMAND_OPTION_PARSERS = Client::COMMANDS.inject({}) do |result, cmd|
233
+ result[cmd] = create_command_option_parser(cmd, command_parser_schemas[cmd])
234
+ result
235
+ end
236
+
237
+ def parse_command_options(command, args)
238
+ COMMAND_OPTION_PARSERS[command].call(args)
239
+ end
240
+ end
241
+ end
@@ -3,22 +3,23 @@ require "uri"
3
3
 
4
4
  module Tweetwine
5
5
  class Client
6
- Dependencies = Struct.new :io, :rest_client, :url_shortener
6
+ Dependencies = Struct.new :io, :http_client, :url_shortener
7
7
 
8
- attr_reader :num_statuses, :page_num
9
-
10
- COMMANDS = [:home, :mentions, :user, :update, :friends, :followers]
8
+ COMMANDS = [:followers, :friends, :home, :mentions, :search, :update, :user]
9
+ DEFAULT_COMMAND = :home
11
10
 
12
11
  DEFAULT_NUM_STATUSES = 20
13
12
  DEFAULT_PAGE_NUM = 1
14
13
  MAX_STATUS_LENGTH = 140
15
14
 
15
+ attr_reader :num_statuses, :page_num
16
+
16
17
  def initialize(dependencies, options)
17
18
  @io = dependencies.io
18
- @rest_client = dependencies.rest_client
19
19
  @username = options[:username].to_s
20
20
  raise ArgumentError, "No authentication data given" if @username.empty?
21
- @base_url = "https://#{@username}:#{options[:password]}@twitter.com/"
21
+ @http_client = dependencies.http_client
22
+ @http_resource = @http_client.as_resource("https://twitter.com", :user => @username, :password => options[:password])
22
23
  @num_statuses = Util.parse_int_gt(options[:num_statuses], DEFAULT_NUM_STATUSES, 1, "number of statuses_to_show")
23
24
  @page_num = Util.parse_int_gt(options[:page_num], DEFAULT_PAGE_NUM, 1, "page number")
24
25
  @url_shortener = if options[:shorten_urls] && options[:shorten_urls][:enable]
@@ -29,95 +30,131 @@ module Tweetwine
29
30
  @status_update_factory = StatusUpdateFactory.new(@io, @url_shortener)
30
31
  end
31
32
 
32
- def home
33
- show_statuses(get_response_as_json("statuses/friends_timeline", :num_statuses, :page))
33
+ def home(args = [], options = nil)
34
+ response = get_from_rest_api("statuses/home_timeline", :num_statuses, :page)
35
+ show_statuses_from_rest_api(*response)
34
36
  end
35
37
 
36
- def mentions
37
- show_statuses(get_response_as_json("statuses/mentions", :num_statuses, :page))
38
+ def mentions(args = [], options = nil)
39
+ response = get_from_rest_api("statuses/mentions", :num_statuses, :page)
40
+ show_statuses_from_rest_api(*response)
38
41
  end
39
42
 
40
- def user(user = @username)
41
- show_statuses(get_response_as_json("statuses/user_timeline/#{user}", :num_statuses, :page))
43
+ def user(args = [], options = nil)
44
+ user = if args.empty? then @username else args.first end
45
+ response = get_from_rest_api("statuses/user_timeline/#{user}", :num_statuses, :page)
46
+ show_statuses_from_rest_api(*response)
42
47
  end
43
48
 
44
- def update(new_status = nil)
45
- new_status = @status_update_factory.prepare(new_status)
49
+ def update(args = [], options = nil)
50
+ new_status = if args.empty? then nil else args.join(" ") end
51
+ new_status = @status_update_factory.create(new_status)
46
52
  completed = false
47
53
  unless new_status.empty?
48
54
  @io.show_status_preview(new_status)
49
55
  if @io.confirm("Really send?")
50
- status = JSON.parse(post("statuses/update.json", {:status => new_status.to_s}))
56
+ response = post_to_rest_api("statuses/update", :status => new_status.to_s)
51
57
  @io.info "Sent status update.\n\n"
52
- show_statuses([status])
58
+ show_statuses_from_rest_api(response)
53
59
  completed = true
54
60
  end
55
61
  end
56
62
  @io.info "Cancelled." unless completed
57
63
  end
58
64
 
59
- def friends
60
- show_users(get_response_as_json("statuses/friends/#{@username}", :page))
65
+ def friends(args = [], options = nil)
66
+ response = get_from_rest_api("statuses/friends/#{@username}")
67
+ show_users_from_rest_api(*response)
68
+ end
69
+
70
+ def followers(args = [], options = nil)
71
+ response = get_from_rest_api("statuses/followers/#{@username}")
72
+ show_users_from_rest_api(*response)
61
73
  end
62
74
 
63
- def followers
64
- show_users(get_response_as_json("statuses/followers/#{@username}", :page))
75
+ def search(args = [], options = nil)
76
+ raise ArgumentError, "No search word" if args.empty?
77
+ query = if options && options[:bin_op] == :or then args.join(" OR ") else args.join(" ") end
78
+ response = get_from_search_api(query, :num_statuses, :page)
79
+ show_statuses_from_search_api(*response["results"])
65
80
  end
66
81
 
67
82
  private
68
83
 
69
- def get_response_as_json(url_body, *query_opts)
70
- url = url_body + ".json?#{parse_query_options(query_opts)}"
71
- JSON.parse(get(url))
84
+ def get_from_rest_api(sub_url, *query_opts)
85
+ query_str = query_options_to_string(query_opts, :page => "page", :num_statuses => "count")
86
+ url_suffix = unless query_str.empty? then "?" << query_str else "" end
87
+ JSON.parse(@http_resource[sub_url + ".json" + url_suffix].get)
72
88
  end
73
89
 
74
- def parse_query_options(query_opts)
75
- str = []
90
+ def post_to_rest_api(sub_url, payload)
91
+ JSON.parse(@http_resource[sub_url + ".json"].post(payload))
92
+ end
93
+
94
+ def get_from_search_api(query, *query_opts)
95
+ query_str = "q=#{Util.percent_encode(query)}&" \
96
+ << query_options_to_string(query_opts, :page => "page", :num_statuses => "rpp")
97
+ JSON.parse(@http_client.get("http://search.twitter.com/search.json?#{query_str}"))
98
+ end
99
+
100
+ def query_options_to_string(query_opts, key_mappings)
101
+ pairs = []
76
102
  query_opts.each do |opt|
77
103
  case opt
78
104
  when :page
79
- str << "page=#{@page_num}"
105
+ pairs << "#{key_mappings[:page]}=#{@page_num}"
80
106
  when :num_statuses
81
- str << "count=#{@num_statuses}"
82
- # do nothing on else
107
+ pairs << "#{key_mappings[:num_statuses]}=#{@num_statuses}"
108
+ # else: ignore unknown query options
83
109
  end
84
110
  end
85
- str.join("&")
111
+ pairs.join("&")
86
112
  end
87
113
 
88
- def show_statuses(data)
89
- show_responses(data) { |entry| [entry["user"], entry] }
90
- end
91
-
92
- def show_users(data)
93
- show_responses(data) { |entry| [entry, entry["status"]] }
94
- end
95
-
96
- def show_responses(data)
97
- data.each do |entry|
98
- user_data, status_data = yield entry
99
- @io.show_record(parse_response(user_data, status_data))
100
- end
114
+ def show_statuses_from_rest_api(*responses)
115
+ show_records(
116
+ responses,
117
+ {
118
+ :from_user => ["user", "screen_name"],
119
+ :to_user => "in_reply_to_screen_name",
120
+ :created_at => "created_at",
121
+ :status => "text"
122
+ }
123
+ )
101
124
  end
102
125
 
103
- def parse_response(user_data, status_data)
104
- record = { :user => user_data["screen_name"] }
105
- if status_data
106
- record[:status] = {
107
- :created_at => status_data["created_at"],
108
- :in_reply_to => status_data["in_reply_to_screen_name"],
109
- :text => status_data["text"]
126
+ def show_users_from_rest_api(*responses)
127
+ show_records(
128
+ responses,
129
+ {
130
+ :from_user => "screen_name",
131
+ :to_user => ["status", "in_reply_to_screen_name"],
132
+ :created_at => ["status", "created_at"],
133
+ :status => ["status", "text"]
110
134
  }
111
- end
112
- record
135
+ )
113
136
  end
114
137
 
115
- def get(body_url)
116
- @rest_client.get @base_url + body_url
138
+ def show_statuses_from_search_api(*responses)
139
+ show_records(
140
+ responses,
141
+ {
142
+ :from_user => "from_user",
143
+ :to_user => "to_user",
144
+ :created_at => "created_at",
145
+ :status => "text"
146
+ }
147
+ )
117
148
  end
118
149
 
119
- def post(body_url, body)
120
- @rest_client.post @base_url + body_url, body
150
+ def show_records(twitter_records, paths)
151
+ twitter_records.each do |twitter_record|
152
+ internal_record = [ :from_user, :to_user, :created_at, :status ].inject({}) do |result, key|
153
+ result[key] = Util.find_hash_path(twitter_record, paths[key])
154
+ result
155
+ end
156
+ @io.show_record(internal_record)
157
+ end
121
158
  end
122
159
 
123
160
  class StatusUpdateFactory
@@ -126,7 +163,7 @@ module Tweetwine
126
163
  @url_shortener = url_shortener
127
164
  end
128
165
 
129
- def prepare(status)
166
+ def create(status)
130
167
  StatusUpdate.new(status, @io, @url_shortener).to_s
131
168
  end
132
169
  end
@@ -168,7 +205,7 @@ module Tweetwine
168
205
  url_pairs.reject { |pair| pair.last.nil? || pair.last.empty? }.each do |url_pair|
169
206
  status.gsub!(url_pair.first, url_pair.last)
170
207
  end
171
- rescue ClientError, LoadError => e
208
+ rescue HttpError, LoadError => e
172
209
  @io.warn "#{e}. Skipping URL shortening..."
173
210
  end
174
211
  end