tq 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: fc0bd8304fbc66b4f921997f047c637f579250b9
4
+ data.tar.gz: d694a8a9b632e17715d78d32a7a2311176d4fed8
5
+ SHA512:
6
+ metadata.gz: f0062de8772a7555928676469d0fa9191c9d36429f0b451d8cdf28ff0225e07d8eac29d69b826f74636110639936209dda94908e9df2d18ddaf9dafcc68076c7
7
+ data.tar.gz: 370c3621670e588217dd84fea9087d94fda54c040bc8776c55ea1f592383835f32c6d0baf366c3ce5052e0f655d729b59e88b0fe3af1069cdb5de706b4ac0983
data/.gems ADDED
@@ -0,0 +1,3 @@
1
+ google-api-client -v 0.8.4
2
+ minitest -v 5.5.1
3
+ parallel -v 1.4.1
data/lib/tq.rb ADDED
@@ -0,0 +1,5 @@
1
+ require_relative 'version'
2
+ require_relative 'tq/app'
3
+ require_relative 'tq/queue'
4
+ require_relative 'tq/logger'
5
+
data/lib/tq/app.rb ADDED
@@ -0,0 +1,167 @@
1
+ require 'google/api_client'
2
+ require 'google/api_client/client_secrets'
3
+ require 'google/api_client/auth/file_storage'
4
+ require 'google/api_client/auth/installed_app'
5
+ require 'parallel'
6
+
7
+ require_relative 'queue'
8
+
9
+ TASKQUEUE_API = 'taskqueue'
10
+ TASKQUEUE_API_VERSION = 'v1beta2'
11
+ TASKQUEUE_API_SCOPES = ['https://www.googleapis.com/auth/taskqueue']
12
+
13
+ module TQ
14
+
15
+ DEFAULT_OPTIONS = {
16
+ 'concurrency' => 2,
17
+ 'log' => {
18
+ 'file' => $stderr
19
+ },
20
+ 'env' => {}
21
+ }
22
+
23
+ class App
24
+
25
+ attr_reader :id, :worker
26
+ def initialize(id, worker, options={})
27
+ @id = id; @worker = worker
28
+ @options = DEFAULT_OPTIONS.merge(options)
29
+ end
30
+
31
+ def options(_)
32
+ App.new @id, @worker, @options.merge(_)
33
+ end
34
+
35
+ def project(_)
36
+ options({'project' => _})
37
+ end
38
+
39
+ def log(_)
40
+ options({'log' => @options['log'].merge(_)})
41
+ end
42
+
43
+ def logger(_)
44
+ options({'logger' => _})
45
+ end
46
+
47
+ def env(_)
48
+ options({'env' => @options['env'].merge(_)})
49
+ end
50
+
51
+ def stdin(_)
52
+ return stdin({'name' => _}) if String === _
53
+ options({'stdin' => _})
54
+ end
55
+
56
+ def stdout(_)
57
+ return stdout({'name' => _}) if String === _
58
+ options({'stdout' => _})
59
+ end
60
+
61
+ def stderr(_)
62
+ return stderr({'name' => _}) if String === _
63
+ options({'stderr' => _})
64
+ end
65
+
66
+ def run!(secrets_file=nil, store_file=nil)
67
+ setup_logger!
68
+ _run *(_queues( TQ::Queue.new( *(auth!(secrets_file, store_file)) ).project(@options['project']) ) )
69
+ end
70
+
71
+
72
+ def service_auth!(issuer, p12_file)
73
+ key = Google::APIClient::KeyUtils.load_from_pkcs12(p12_file, 'notasecret')
74
+ client.authorization = Signet::OAuth2::Client.new(
75
+ :token_credential_uri => 'https://accounts.google.com/o/oauth2/token',
76
+ :audience => 'https://accounts.google.com/o/oauth2/token',
77
+ :scope => TASKQUEUE_API_SCOPES,
78
+ :issuer => issuer,
79
+ :signing_key => key)
80
+ client.authorization.fetch_access_token!
81
+
82
+ api = client.discovered_api(TASKQUEUE_API, TASKQUEUE_API_VERSION)
83
+
84
+ return client, api
85
+ end
86
+
87
+ def auth!(secrets_file=nil, store_file=nil)
88
+ if store_file.nil? || (cred_store = credentials_store(store_file)).authorization.nil?
89
+ client_secrets = Google::APIClient::ClientSecrets.load(secrets_file)
90
+ flow = Google::APIClient::InstalledAppFlow.new(
91
+ :client_id => client_secrets.client_id,
92
+ :client_secret => client_secrets.client_secret,
93
+ :scope => TASKQUEUE_API_SCOPES
94
+ )
95
+ client.authorization = store_file.nil? ?
96
+ flow.authorize :
97
+ flow.authorize(cred_store)
98
+ else
99
+ client.authorization = cred_store.authorization
100
+ end
101
+
102
+ api = client.discovered_api(TASKQUEUE_API, TASKQUEUE_API_VERSION)
103
+
104
+ return client, api
105
+ end
106
+
107
+ def application_name
108
+ @id.split('/')[0]
109
+ end
110
+
111
+ def application_version
112
+ @id.split('/')[1] || '0.0.0'
113
+ end
114
+
115
+ private
116
+
117
+ def setup_logger!
118
+ if logger = @options['logger']
119
+ else
120
+ if (log = @options['log']) && (file = log['file'])
121
+ logger = Logger.new(file)
122
+ if level = log['level']
123
+ logger.level = level
124
+ end
125
+ end
126
+ end
127
+ (Google::APIClient.logger = logger) if logger
128
+ end
129
+
130
+ def client
131
+ @client ||= Google::APIClient.new(
132
+ :application_name => application_name,
133
+ :application_version => application_version
134
+ )
135
+ end
136
+
137
+ def credentials_store(file)
138
+ Google::APIClient::FileStorage.new(file)
139
+ end
140
+
141
+ def _queues(q)
142
+ qin = @options['stdin'] && q.options(@options['stdin'])
143
+ qout = @options['stdout'] && q.options(@options['stdout'])
144
+ qerr = @options['stderr'] && q.options(@options['stderr'])
145
+ return qin, qout, qerr
146
+ end
147
+
148
+ # TODO handle uncaught worker errors by qerr.push!(err) and qin.finish!(task)
149
+ # TODO raise if not qin
150
+ def _run(qin, qout, qerr)
151
+ tasks = qin.lease!
152
+ Parallel.each(tasks, :in_threads => @options['concurrency']) do |task|
153
+ @worker.new(qin, qout, qerr, inherited_env).call(task)
154
+ end
155
+ end
156
+
157
+ # default log/logger options into env
158
+ def inherited_env
159
+ env = @options['env']
160
+ log = @options['log']
161
+ logger = @options['logger']
162
+ {'log' => log, 'logger' => logger}.merge(env)
163
+ end
164
+
165
+ end
166
+
167
+ end
data/lib/tq/logger.rb ADDED
@@ -0,0 +1,93 @@
1
+ require 'logger'
2
+ require 'time'
3
+
4
+ module TQ
5
+
6
+ class Logger
7
+
8
+ DEFAULT_OPTIONS = {
9
+ 'file' => $stderr,
10
+ 'level' => ::Logger::WARN
11
+ }
12
+
13
+ def initialize(queue, options={})
14
+ @queue = queue
15
+ if Hash === options
16
+ @log = build_log( DEFAULT_OPTIONS.merge(options) )
17
+ else
18
+ @log = options
19
+ end
20
+ end
21
+
22
+ def level
23
+ @log.level
24
+ end
25
+
26
+ def level=(severity)
27
+ @log.level = severity
28
+ end
29
+
30
+ def progname
31
+ @log.progname
32
+ end
33
+
34
+ def progname=(name)
35
+ @log.progname = name
36
+ end
37
+
38
+ def add(severity, message=nil, progname=nil, context=nil, &block)
39
+ t = Time.now
40
+ @log.add(severity, message, progname, &block)
41
+ @queue.push!(
42
+ queue_message(t, severity, message, progname, context, &block),
43
+ ::Logger::SEV_LABEL[severity].to_s.downcase
44
+ ) if (severity >= level)
45
+ end
46
+
47
+ alias log add
48
+
49
+ ::Logger::SEV_LABEL.each_with_index do |label,level|
50
+ define_method(label == 'ANY' ? 'unknown' : label.to_s.downcase) do |progname=nil, context=nil, &block|
51
+ add(level, nil, progname, context, &block)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def build_log(options)
58
+ return options if options.respond_to?(:log)
59
+ logger = ::Logger.new(options['file'], options['shift_age'], options['shift_size'])
60
+ logger.level = options['level'] if options['level']
61
+ logger.progname = options['progname'] if options['progname']
62
+ return logger
63
+ end
64
+
65
+ # damn, the ruby logger interface is weird... this logic is copied almost verbatim
66
+ # from ::Logger.add
67
+ def queue_message(t, severity, message, progname, context, &block)
68
+ severity ||= ::Logger::UNKNOWN
69
+ progname ||= self.progname
70
+ if message.nil?
71
+ if block_given?
72
+ message = yield
73
+ else
74
+ message = progname
75
+ progname = self.progname
76
+ end
77
+ end
78
+
79
+ return {
80
+ time: t.iso8601,
81
+ timestamp: t.to_i,
82
+ level: severity,
83
+ label: ::Logger::SEV_LABEL[severity],
84
+ message: message,
85
+ progname: progname,
86
+ context: context
87
+ }
88
+ end
89
+
90
+ end
91
+
92
+ end
93
+
data/lib/tq/queue.rb ADDED
@@ -0,0 +1,138 @@
1
+
2
+ module TQ
3
+
4
+ class Queue
5
+
6
+ DEFAULT_OPTIONS = { 'lease_secs' => 60, 'num_tasks' => 1 }
7
+
8
+ attr_reader :client, :api
9
+ def initialize(client, api, options={})
10
+ @client, @api = client, api
11
+ @options = DEFAULT_OPTIONS.merge(options)
12
+ end
13
+
14
+ def options(_)
15
+ Queue.new @client, @api, @options.merge(_)
16
+ end
17
+
18
+ def project(_)
19
+ options({'project' => _})
20
+ end
21
+
22
+ def name(_)
23
+ options({'name' => _})
24
+ end
25
+
26
+ def lease!(opts={})
27
+ opts = @options.merge(opts)
28
+ results = client.execute!(
29
+ :api_method => api.tasks.lease,
30
+ :parameters => { :leaseSecs => opts['lease_secs'],
31
+ :project => opts['project'],
32
+ :taskqueue => opts['name'],
33
+ :numTasks => opts['num_tasks']
34
+ }
35
+ )
36
+ items = (results.data && results.data['items']) || []
37
+ items.map {|t| new_task(t) }
38
+ end
39
+
40
+ # note: does not currently work; filed bug report https://code.google.com/p/googleappengine/issues/detail?id=11838
41
+ def extend!(task, secs=nil)
42
+ secs = secs.nil? ? @options['lease_secs'] : secs
43
+ opts = @options
44
+ results = client.execute!(
45
+ :api_method => api.tasks.update,
46
+ :parameters => { :newLeaseSeconds => secs,
47
+ :project => opts['project'],
48
+ :taskqueue => opts['name'],
49
+ :task => task.id
50
+ }
51
+ )
52
+ new_task(results.data)
53
+ end
54
+
55
+ def push!(payload, tag=nil)
56
+ opts = @options
57
+ body = { 'queueName' => opts['name'],
58
+ 'payloadBase64' => encode(payload)
59
+ }
60
+ body['tag'] = tag if tag
61
+
62
+ results = client.execute!(
63
+ :api_method => api.tasks.insert,
64
+ :parameters => { :project => opts['project'],
65
+ :taskqueue => opts['name']
66
+ },
67
+ :body_object => body
68
+ )
69
+ new_task(results.data)
70
+ end
71
+
72
+ # note: you must have previously leased given task
73
+ def finish!(task)
74
+ opts = @options
75
+ client.execute!( :api_method => api.tasks.delete,
76
+ :parameters => { :project => opts['project'],
77
+ :taskqueue => opts['name'],
78
+ :task => task.id
79
+ }
80
+ )
81
+ return
82
+ end
83
+
84
+ private
85
+
86
+ def new_task(t)
87
+ Task.new(self, t['id'], timestamp_time(t['leaseTimestamp']), decode(t.payloadBase64), t)
88
+ end
89
+
90
+ def timestamp_time(t)
91
+ Time.at( t / 1000000 )
92
+ end
93
+
94
+ def encode(obj)
95
+ Base64.urlsafe_encode64(JSON.dump(obj))
96
+ end
97
+
98
+ def decode(str)
99
+ JSON.load(Base64.urlsafe_decode64(str))
100
+ end
101
+
102
+ end
103
+
104
+ class Task < Struct.new(:queue, :id, :expires, :payload, :raw)
105
+
106
+ def initialize(*args)
107
+ super
108
+ @clock = Time
109
+ end
110
+
111
+ def finish!
112
+ self.queue.finish!(self)
113
+ end
114
+
115
+ def extend!(secs=nil)
116
+ self.queue.extend!(self, secs)
117
+ end
118
+
119
+ def clock!(_)
120
+ @clock = _; return self
121
+ end
122
+
123
+ def reset_clock!
124
+ @clock = Time; return self
125
+ end
126
+
127
+ def lease_remaining
128
+ self.expires - @clock.now
129
+ end
130
+
131
+ def lease_expired?
132
+ self.expires < @clock.now
133
+ end
134
+
135
+ end
136
+
137
+ end
138
+
data/lib/tq/shell.rb ADDED
@@ -0,0 +1,85 @@
1
+ require 'optparse'
2
+ require 'json'
3
+ require_relative '../version'
4
+
5
+ module TQ
6
+
7
+ class Shell
8
+
9
+ DEFAULT_OPTIONS = {
10
+ app: {},
11
+ auth_secrets_file: './tq-client.json',
12
+ auth_store_file: './tq-client-store.json',
13
+ config_file: './tq-app.json'
14
+ }
15
+
16
+ def initialize(app, logger=nil)
17
+ @app = app
18
+ @logger = logger
19
+ @summary = []
20
+ end
21
+
22
+ def banner(_)
23
+ @banner = _; return self
24
+ end
25
+
26
+ def summary(*_)
27
+ @summary = _; return self
28
+ end
29
+
30
+ def call(argv=ARGV)
31
+
32
+ progname = File.basename(__FILE__,'.rb')
33
+
34
+ @logger.info(progname) { "Configuring #{@app.id}" } if @logger
35
+ opts = parse_args(argv)
36
+ @logger.debug(progname) { "Configuration: #{opts[:app].inspect}" } if @logger
37
+
38
+ @app = @app.options( opts[:app] ).logger(@logger)
39
+
40
+ @logger.info(progname) { "Running #{@app.id} using worker #{@app.worker}" } if @logger
41
+ @app.run!( opts[:auth_secrets_file], opts[:auth_store_file] )
42
+
43
+ end
44
+
45
+ private
46
+
47
+ def parse_args(argv)
48
+ opts = {}.merge(DEFAULT_OPTIONS)
49
+
50
+ OptionParser.new do |shell|
51
+
52
+ (shell.banner = @banner) if @banner
53
+ @summary.each do |line|
54
+ shell.separator line
55
+ end
56
+
57
+ shell.on('-a', '--auth-secrets [FILE]', "Google OAuth2 secrets file") do |given|
58
+ opts[:auth_secrets_file] = given
59
+ end
60
+
61
+ shell.on('-s', '--auth-store [FILE]', "Google OAuth2 storage file") do |given|
62
+ opts[:auth_store_file] = given
63
+ end
64
+
65
+ shell.on('-c', '--config [FILE]', "Application config file (json)") do |given|
66
+ opts[:config_file] = given
67
+ opts[:app] = JSON.load( File.open(given, 'r') )
68
+ end
69
+
70
+ shell.on('-h', '--help', "Prints this help") do |given|
71
+ puts shell; exit
72
+ end
73
+
74
+ shell.on('-v', '--version', "Prints TQ version") do |given|
75
+ puts TQ::VERSION; exit
76
+ end
77
+
78
+ end.parse(argv)
79
+
80
+ return opts
81
+ end
82
+
83
+ end
84
+
85
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,4 @@
1
+
2
+ module TQ
3
+ VERSION = '0.1.3'
4
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,120 @@
1
+ require 'fileutils'
2
+ require 'logger'
3
+
4
+ gem 'minitest'
5
+ require 'minitest/autorun'
6
+
7
+ require 'google/api_client'
8
+
9
+ module TestUtils
10
+ extend self
11
+
12
+ def setup_logger(name="test")
13
+ file = File.expand_path("log/#{name}.log", File.dirname(__FILE__))
14
+ FileUtils.mkdir_p( File.dirname(file) )
15
+ logger = Logger.new( File.open(file, 'w' ) )
16
+ logger.level = ENV['DEBUG'] ? Logger::DEBUG : Logger::INFO
17
+ Google::APIClient.logger = logger
18
+ end
19
+
20
+ def current_logger
21
+ Google::APIClient.logger
22
+ end
23
+
24
+ class QueueHelper
25
+
26
+ attr_reader :project, :queue
27
+ def initialize(project,queue)
28
+ @project, @queue = project, queue
29
+ end
30
+
31
+ def auth_files(secrets,creds)
32
+ @secrets_file, @creds_file = secrets, creds
33
+ return self
34
+ end
35
+
36
+ def authorized_client
37
+ # @authorized_client ||= TQ::App.new('test_app',nil).service_auth!(File.read(SERVICE_ISSUER_FILE).chomp, SERVICE_P12_FILE)
38
+ @authorized_client ||= TQ::App.new('test_app',nil).auth!(@secrets_file, @creds_file)
39
+ end
40
+
41
+ # Note: inaccurate, don't use
42
+ def peek()
43
+ client, api = authorized_client
44
+ results = client.execute!( :api_method => api.tasks.list,
45
+ :parameters => { :project => project, :taskqueue => queue }
46
+ )
47
+ items = results.data['items'] || []
48
+ end
49
+
50
+ def push!(payload)
51
+ client, api = authorized_client
52
+ client.execute!( :api_method => api.tasks.insert,
53
+ :parameters => { :project => project, :taskqueue => queue },
54
+ :body_object => {
55
+ 'queueName' => queue,
56
+ 'payloadBase64' => encode(payload)
57
+ }
58
+ )
59
+ end
60
+
61
+ def pop!(n=1)
62
+ client, api = authorized_client
63
+ results = client.execute!( :api_method => api.tasks.lease,
64
+ :parameters => { :project => project, :taskqueue => queue,
65
+ :leaseSecs => 60, :numTasks => n
66
+ }
67
+ )
68
+ items = results.data['items'] || []
69
+ items.each do |item|
70
+ client.execute!( :api_method => api.tasks.delete,
71
+ :parameters => { :project => project, :taskqueue => queue, :task => item['id'] }
72
+ )
73
+ end
74
+ return items
75
+ end
76
+
77
+ def all_payloads!
78
+ map! { |t| decode(t['payloadBase64']) }
79
+ end
80
+
81
+ def all_tags!
82
+ map! { |t| t['tag'] }
83
+ end
84
+
85
+ def all_payloads_and_tags!
86
+ map! { |t| {:payload => decode(t['payloadBase64']),
87
+ :tag => t['tag']
88
+ }
89
+ }
90
+ end
91
+
92
+ def map!(&b)
93
+ clear!.map(&b)
94
+ end
95
+
96
+ def clear!
97
+ client, api = authorized_client
98
+ done = false
99
+ all = []
100
+ while !done do
101
+ batch = pop!(10)
102
+ done = batch.empty? || batch.length < 10
103
+ all = all + batch
104
+ end
105
+ all
106
+ end
107
+
108
+ def encode(obj)
109
+ Base64.urlsafe_encode64(JSON.dump(obj))
110
+ end
111
+
112
+ def decode(str)
113
+ JSON.load(Base64.urlsafe_decode64(str))
114
+ end
115
+
116
+ end
117
+
118
+
119
+ end
120
+
data/test/suite.rb ADDED
@@ -0,0 +1,4 @@
1
+
2
+ %w[ test_shell test_run test_logger ].each do |rb|
3
+ require_relative rb
4
+ end
data/test/test_auth.rb ADDED
@@ -0,0 +1,63 @@
1
+ require 'fileutils'
2
+ require_relative './helper'
3
+ require_relative '../lib/tq/app'
4
+
5
+ def setup_logger!(name)
6
+ TestUtils.setup_logger(name)
7
+ end
8
+
9
+ def delete_credentials!
10
+ FileUtils.rm_f(CREDENTIALS_FILE)
11
+ end
12
+
13
+ # for installed app auth
14
+ CLIENT_SECRETS_FILE = File.expand_path('../config/secrets/test/client_secrets.json', File.dirname(__FILE__))
15
+ CREDENTIALS_FILE = File.expand_path("../config/secrets/test/#{File.basename(__FILE__,'.rb')}-oauth2.json", File.dirname(__FILE__))
16
+
17
+ # for service account auth -- not quite working
18
+ SERVICE_ISSUER_FILE = File.expand_path('../config/secrets/test/issuer', File.dirname(__FILE__))
19
+ SERVICE_P12_FILE = File.expand_path('../config/secrets/test/client.p12', File.dirname(__FILE__))
20
+
21
+ describe TQ::App do
22
+
23
+ describe "auth" do
24
+
25
+ before do
26
+ setup_logger!('auth')
27
+ delete_credentials!
28
+ end
29
+
30
+ it "should authorize without cached credentials" do
31
+ app = TQ::App.new('test_app/0.0.0', nil)
32
+ app.auth! CLIENT_SECRETS_FILE
33
+ assert true
34
+ end
35
+
36
+ # Note: browser window should only appear once for this test
37
+ # Not sure if it's possible to assert this
38
+ it "should authorize with cached credentials not existing and existing" do
39
+ app = TQ::App.new('test_app/0.0.0', nil)
40
+ app.auth! CLIENT_SECRETS_FILE, CREDENTIALS_FILE
41
+ assert File.exists?(CREDENTIALS_FILE)
42
+ app.auth! CLIENT_SECRETS_FILE, CREDENTIALS_FILE
43
+ assert File.exists?(CREDENTIALS_FILE)
44
+ end
45
+
46
+ end
47
+
48
+ describe "service auth" do
49
+
50
+ before do
51
+ setup_logger!('auth')
52
+ end
53
+
54
+ it "should authorize service account" do
55
+ app = TQ::App.new('test_app/0.0.0', nil)
56
+ app.service_auth!(File.read(SERVICE_ISSUER_FILE).chomp, SERVICE_P12_FILE)
57
+ assert true
58
+ end
59
+
60
+ end
61
+
62
+ end
63
+
@@ -0,0 +1,184 @@
1
+ # Functional tests of TQ::Logger
2
+ # Please note that these require a deployed GAE app
3
+ # with two queues: 'test' and 'log'.
4
+ # The GAE project_id is defined in ../config/secrets/test/project_id,
5
+ # along with other secrets files (see below).
6
+
7
+ require 'logger'
8
+
9
+ require_relative './helper'
10
+ require_relative '../lib/tq/app'
11
+ require_relative '../lib/tq/queue'
12
+ require_relative '../lib/tq/logger'
13
+
14
+ def setup_test_logger!
15
+ TestUtils.setup_logger( File.basename(__FILE__,'.rb') )
16
+ end
17
+
18
+
19
+ class LoggerTests < Minitest::Spec
20
+
21
+ # for installed app auth
22
+ CLIENT_SECRETS_FILE = File.expand_path(
23
+ '../config/secrets/test/client_secrets.json', File.dirname(__FILE__)
24
+ )
25
+ CREDENTIALS_FILE = File.expand_path(
26
+ "../config/secrets/test/#{File.basename(__FILE__,'.rb')}-oauth2.json",
27
+ File.dirname(__FILE__)
28
+ )
29
+
30
+ # task queue constants
31
+ TASKQUEUE_PROJECT_ID = File.read(
32
+ File.expand_path('../config/secrets/test/project_id', File.dirname(__FILE__))
33
+ ).chomp
34
+
35
+ TASKQUEUE_TEST_QUEUE = 'log'
36
+
37
+ def test_logger
38
+ @test_logger ||= TestUtils.current_logger
39
+ end
40
+
41
+ def assert_logged( exps, acts )
42
+ errs = []
43
+ unless (a = exps.length) == (b = acts.length)
44
+ errs.push( "Expected #{a} messages, #{b < 2 ? 'were' : 'was'} #{b}" )
45
+ end
46
+ exps.each_with_index do |exp,i|
47
+ act = acts[i]
48
+ next if act.nil?
49
+ [:level, :message, :progname].each do |elem|
50
+ unless (a = exp[elem]) == (b = act[elem.to_s])
51
+ errs.push( "[#{i}] Expected #{elem} to be #{a}, was #{b}" )
52
+ end
53
+ end
54
+ unless (a = ::Logger::SEV_LABEL[exp[:level]]) == (b = act['label'])
55
+ errs.push( "[#{i}] Expected label to be #{a}, was #{b}" )
56
+ end
57
+ end
58
+ assert errs.length == 0, errs.join("\n")
59
+ end
60
+
61
+ def assert_logged_tags( exps, acts )
62
+ errs = []
63
+ unless (a = exps.length) == (b = acts.length)
64
+ errs.push( "Expected #{a} messages, #{b < 2 ? 'were' : 'was'} #{b}" )
65
+ end
66
+ exps.each_with_index do |exp,i|
67
+ act = acts[i]
68
+ next if act.nil?
69
+ unless (a = exp[:method].to_s) == (b = act)
70
+ errs.push( "[#{i}] Expected tag to be #{a}, was #{b}" )
71
+ end
72
+ end
73
+ assert errs.length == 0, errs.join("\n")
74
+ end
75
+
76
+ def send_messages_to!(logger, msgs)
77
+ msgs.each do |msg|
78
+ logger.__send__(msg[:method], msg[:progname], msg[:context]) { msg[:message] }
79
+ end
80
+ end
81
+
82
+ def queue_helper(project,queue)
83
+ TestUtils::QueueHelper.new(project,queue).auth_files(CLIENT_SECRETS_FILE, CREDENTIALS_FILE)
84
+ end
85
+
86
+ def clear_queue!(project,queue)
87
+ queue_helper(project,queue).clear!
88
+ end
89
+
90
+ def verify_logged_messages_to_level!(expected_messages, minlevel)
91
+ actual_messages = queue_helper(TASKQUEUE_PROJECT_ID, TASKQUEUE_TEST_QUEUE).all_payloads_and_tags!
92
+ $stderr.puts actual_messages.inspect
93
+
94
+ selected_messages = expected_messages.select { |msg| msg[:level] >= minlevel }
95
+
96
+ assert_logged( selected_messages,
97
+ actual_messages.map { |msg| msg[:payload] }
98
+ )
99
+
100
+ assert_logged_tags( selected_messages,
101
+ actual_messages.map { |msg| msg[:tag] }
102
+ )
103
+ end
104
+
105
+ # Note: ideally these tests would also verify the (file) logger output as well
106
+ # and the timestamps, mocking the clock, etc. But that's a mess of work, so for now
107
+ # you should just eyeball it in the $stderr output.
108
+
109
+ def setup
110
+ clear_queue!(TASKQUEUE_PROJECT_ID, TASKQUEUE_TEST_QUEUE)
111
+
112
+ app = TQ::App.new('test_app/0.0.0', nil)
113
+ @queue = TQ::Queue.new( *app.auth!(CLIENT_SECRETS_FILE, CREDENTIALS_FILE) )
114
+ .options({ 'project' => TASKQUEUE_PROJECT_ID, 'name' => TASKQUEUE_TEST_QUEUE })
115
+ end
116
+
117
+ it 'default logger should log to queue at warn level' do
118
+ subject = TQ::Logger.new(@queue)
119
+
120
+ expected_messages = [
121
+ { method: :debug, level: ::Logger::DEBUG, message: 'debug message', progname: 'prog1', context: { key: 1 } },
122
+ { method: :info, level: ::Logger::INFO, message: 'info message', progname: 'prog2', context: { key: 2 } },
123
+ { method: :warn, level: ::Logger::WARN, message: 'warn message', progname: 'prog3', context: { key: 3 } },
124
+ { method: :error, level: ::Logger::ERROR, message: 'error message', progname: 'prog4', context: { key: 4 } }
125
+ ]
126
+
127
+ send_messages_to! subject, expected_messages
128
+
129
+ verify_logged_messages_to_level! expected_messages, ::Logger::WARN
130
+
131
+ end
132
+
133
+ it 'after setting level to debug, logger should log to queue at debug level' do
134
+ subject = TQ::Logger.new(@queue)
135
+ subject.level = ::Logger::DEBUG
136
+
137
+ expected_messages = [
138
+ { method: :debug, level: ::Logger::DEBUG, message: 'debug message', progname: 'prog1', context: { key: 1 } },
139
+ { method: :info, level: ::Logger::INFO, message: 'info message', progname: 'prog2', context: { key: 2 } },
140
+ { method: :warn, level: ::Logger::WARN, message: 'warn message', progname: 'prog3', context: { key: 3 } },
141
+ { method: :error, level: ::Logger::ERROR, message: 'error message', progname: 'prog4', context: { key: 4 } }
142
+ ]
143
+
144
+ send_messages_to!(subject, expected_messages)
145
+
146
+ verify_logged_messages_to_level! expected_messages, ::Logger::DEBUG
147
+
148
+ end
149
+
150
+ it 'when setting level to debug in config, logger should log to queue at debug level' do
151
+ subject = TQ::Logger.new(@queue, {'level' => ::Logger::DEBUG } )
152
+
153
+ expected_messages = [
154
+ { method: :debug, level: ::Logger::DEBUG, message: 'debug message', progname: 'prog1', context: { key: 1 } },
155
+ { method: :info, level: ::Logger::INFO, message: 'info message', progname: 'prog2', context: { key: 2 } },
156
+ { method: :warn, level: ::Logger::WARN, message: 'warn message', progname: 'prog3', context: { key: 3 } },
157
+ { method: :error, level: ::Logger::ERROR, message: 'error message', progname: 'prog4', context: { key: 4 } }
158
+ ]
159
+
160
+ send_messages_to!(subject, expected_messages)
161
+
162
+ verify_logged_messages_to_level! expected_messages, ::Logger::DEBUG
163
+
164
+ end
165
+
166
+ it 'when setting external logger, logger should log to queue at logger\'s level' do
167
+ subject = TQ::Logger.new(@queue, test_logger )
168
+
169
+ expected_messages = [
170
+ { method: :debug, level: ::Logger::DEBUG, message: 'debug message', progname: 'prog1', context: { key: 1 } },
171
+ { method: :info, level: ::Logger::INFO, message: 'info message', progname: 'prog2', context: { key: 2 } },
172
+ { method: :warn, level: ::Logger::WARN, message: 'warn message', progname: 'prog3', context: { key: 3 } },
173
+ { method: :error, level: ::Logger::ERROR, message: 'error message', progname: 'prog4', context: { key: 4 } }
174
+ ]
175
+
176
+ send_messages_to!(subject, expected_messages)
177
+
178
+ verify_logged_messages_to_level! expected_messages, test_logger.level
179
+
180
+ end
181
+
182
+ end
183
+
184
+ setup_test_logger!
data/test/test_run.rb ADDED
@@ -0,0 +1,239 @@
1
+ # Functional tests of TQ::App#run!
2
+ # Please note that these require a deployed GAE app
3
+ # with two queues: 'test' and 'log'.
4
+ # The GAE project_id is defined in ../config/secrets/test/project_id,
5
+ # along with other secrets files (see below).
6
+
7
+ require_relative './helper'
8
+ require_relative '../lib/tq/app'
9
+
10
+ def setup_test_logger!
11
+ TestUtils.setup_logger( File.basename(__FILE__,'.rb') )
12
+ end
13
+
14
+ class AppRunTests < Minitest::Spec
15
+
16
+ # for installed app auth
17
+ CLIENT_SECRETS_FILE = File.expand_path(
18
+ '../config/secrets/test/client_secrets.json', File.dirname(__FILE__)
19
+ )
20
+ CREDENTIALS_FILE = File.expand_path(
21
+ "../config/secrets/test/#{File.basename(__FILE__,'.rb')}-oauth2.json",
22
+ File.dirname(__FILE__)
23
+ )
24
+
25
+ # for service account auth -- not quite working
26
+ SERVICE_ISSUER_FILE = File.expand_path(
27
+ '../config/secrets/test/issuer', File.dirname(__FILE__)
28
+ )
29
+ SERVICE_P12_FILE = File.expand_path(
30
+ '../config/secrets/test/client.p12', File.dirname(__FILE__)
31
+ )
32
+
33
+ # task queue constants
34
+ TASKQUEUE_PROJECT_ID = File.read(
35
+ File.expand_path('../config/secrets/test/project_id', File.dirname(__FILE__))
36
+ ).chomp
37
+
38
+ TASKQUEUE_LEASE_SECS = 2
39
+
40
+
41
+ def queue_helper(project,queue)
42
+ TestUtils::QueueHelper.new(project,queue).auth_files(CLIENT_SECRETS_FILE, CREDENTIALS_FILE)
43
+ end
44
+
45
+ def tasks_on_queue(project,queue)
46
+ queue_helper(project,queue).list
47
+ end
48
+
49
+ def clear_queue!(project,queue)
50
+ queue_helper(project,queue).clear!
51
+ end
52
+
53
+ def push_tasks!(project,queue,tasks)
54
+ q = queue_helper(project,queue)
55
+ tasks.each do |task| q.push!(task) end
56
+ end
57
+
58
+
59
+ describe "run!" do
60
+
61
+ before do
62
+ sleep TASKQUEUE_LEASE_SECS + 1 # to wait for lease expiry from previous test
63
+ clear_queue!(TASKQUEUE_PROJECT_ID,'test')
64
+ clear_queue!(TASKQUEUE_PROJECT_ID,'log')
65
+ end
66
+
67
+ it "should setup clearing the queue" do
68
+ cleared = clear_queue!(TASKQUEUE_PROJECT_ID,'test')
69
+ assert_equal 0, n = cleared.length,
70
+ "Expected no tasks on queue, #{n < 2 ? 'was' : 'were'} #{n}"
71
+
72
+ # unfortunately, queue peeks are horribly inaccurate right now.
73
+ # assert_equal 0, n = tasks_on_queue(TASKQUEUE_PROJECT_ID,'test').length,
74
+ # "Expected no tasks on queue, #{n < 2 ? 'was' : 'were'} #{n}"
75
+ end
76
+
77
+ it "worker should receive input queue and :call with each task on the queue up to the specified number" do
78
+
79
+ # setup
80
+ expected_tasks = [
81
+ { 'What is your name?' => 'Sir Lancelot', 'What is your quest?' => 'To seek the holy grail', 'What is your favorite color?' => 'blue' },
82
+ { 'What is your name?' => 'Sir Robin', 'What is your quest?' => 'To seek the holy grail', 'What is the capital of Assyria?' => nil },
83
+ { 'What is your name?' => 'Galahad', 'What is your quest?' => 'To seek the grail', 'What is your favorite color?' => ['blue','yellow'] },
84
+ { 'What is your name?' => 'Arthur', 'What is your quest?' => 'To seek the holy grail', 'What is the air-speed velocity of an unladen swallow?' => 'African or European swallow?' }
85
+ ]
86
+ push_tasks!(TASKQUEUE_PROJECT_ID,'test', expected_tasks)
87
+
88
+ # expectations
89
+ mock_handler_class = MiniTest::Mock.new
90
+ mock_handler = MiniTest::Mock.new
91
+
92
+ ## expect constructor receives task queue as first param -- for each queued task up to expected_instances
93
+ expected_calls = 3
94
+ (0...expected_calls).each do
95
+ mock_handler_class.expect(:new, mock_handler) do |*args|
96
+ args.first.respond_to?('finish!')
97
+ end
98
+ end
99
+
100
+ ## expect :call for each queued task up to expected_calls
101
+ actual_calls = 0;
102
+ (0...expected_calls).each do
103
+ mock_handler.expect(:call, true) do |actual_task|
104
+ actual_calls += 1
105
+ valid = !!actual_task && actual_task.payload.has_key?('What is your name?')
106
+ valid.tap do |yes|
107
+ $stderr.puts("mock_handler.call - received: #{ actual_task.payload }") if yes
108
+ end
109
+ end
110
+ end
111
+
112
+ # execution
113
+ app = TQ::App.new('test_app/0.0.0', mock_handler_class)
114
+ .project(TASKQUEUE_PROJECT_ID)
115
+ .stdin({ 'name' => 'test', 'num_tasks' => expected_calls, 'lease_secs' => TASKQUEUE_LEASE_SECS })
116
+ app.run! CLIENT_SECRETS_FILE, CREDENTIALS_FILE
117
+
118
+ # assertions
119
+ assert_equal expected_calls, actual_calls,
120
+ "Expected #{expected_calls} worker calls, was #{actual_calls}"
121
+
122
+ mock_handler_class.verify
123
+ mock_handler.verify
124
+
125
+ end
126
+
127
+ it 'should put task back on input queue after lease_secs, if not finished' do
128
+
129
+ # setup
130
+ expected_tasks = [
131
+ { 'What is your name?' => 'Sir Lancelot', 'What is your quest?' => 'To seek the holy grail', 'What is your favorite color?' => 'blue' }
132
+ ]
133
+ push_tasks!(TASKQUEUE_PROJECT_ID,'test', expected_tasks)
134
+
135
+ class DoNothingWorker
136
+ def initialize(*args); end
137
+ def call(task); end
138
+ end
139
+
140
+ # execution
141
+ app = TQ::App.new('test_app/0.0.0', DoNothingWorker)
142
+ .project(TASKQUEUE_PROJECT_ID)
143
+ .stdin({ 'name' => 'test', 'num_tasks' => 1, 'lease_secs' => TASKQUEUE_LEASE_SECS })
144
+ app.run! CLIENT_SECRETS_FILE, CREDENTIALS_FILE
145
+
146
+ sleep TASKQUEUE_LEASE_SECS + 1
147
+ actual_tasks = clear_queue!(TASKQUEUE_PROJECT_ID,'test')
148
+
149
+ ## queue peeks are inaccurate right now...
150
+ # actual_tasks = tasks_on_queue(TASKQUEUE_PROJECT_ID,'test')
151
+
152
+ ## assertion
153
+ assert_equal a = expected_tasks.length, b = actual_tasks.length,
154
+ "Expected #{a} #{a == 1 ? 'task' : 'tasks'} on queue, #{b < 2 ? 'was' : 'were'} #{b}"
155
+
156
+ assert (n = actual_tasks.first['retry_count']) > 0,
157
+ "Expected >0 lease retry count, #{n < 2 ? 'was' : 'were'} #{n}"
158
+
159
+ end
160
+
161
+ it 'should be able to push to queue from within worker' do
162
+
163
+ # setup
164
+ expected_tasks = [
165
+ { 'What is your name?' => 'Sir Lancelot', 'What is your quest?' => 'To seek the holy grail', 'What is your favorite color?' => 'blue' }
166
+ ]
167
+ push_tasks!(TASKQUEUE_PROJECT_ID,'test', expected_tasks)
168
+
169
+ class RelayWorker
170
+ def initialize(*args)
171
+ @stdin = args.first; @stdout = args[1]
172
+ end
173
+
174
+ def call(task)
175
+ @stdout.push!(task.payload)
176
+ task.finish!
177
+ end
178
+ end
179
+
180
+ # execution
181
+ app = TQ::App.new('test_app/0.0.0', RelayWorker)
182
+ .project(TASKQUEUE_PROJECT_ID)
183
+ .stdin({ 'name' => 'test', 'num_tasks' => 1, 'lease_secs' => TASKQUEUE_LEASE_SECS })
184
+ .stdout({ 'name' => 'log' })
185
+ app.run! CLIENT_SECRETS_FILE, CREDENTIALS_FILE
186
+
187
+ sleep TASKQUEUE_LEASE_SECS + 1
188
+ actual_output_tasks = clear_queue!(TASKQUEUE_PROJECT_ID,'log')
189
+ actual_input_tasks = clear_queue!(TASKQUEUE_PROJECT_ID,'test')
190
+
191
+ # assertion
192
+
193
+ assert_equal 0, b = actual_input_tasks.length,
194
+ "Expected no tasks on input queue, #{b < 2 ? 'was' : 'were'} #{b}"
195
+
196
+ assert_equal a = expected_tasks.length, b = actual_output_tasks.length,
197
+ "Expected #{a} #{a == 1 ? 'task' : 'tasks'} on output queue, #{b < 2 ? 'was' : 'were'} #{b}"
198
+
199
+ end
200
+
201
+ it 'should extend a task lease if extended before lease expires' do
202
+
203
+ refute true, "Extending task leases does not currently work"
204
+
205
+ # setup
206
+ expected_tasks = [
207
+ { 'What is your name?' => 'Sir Lancelot', 'What is your quest?' => 'To seek the holy grail', 'What is your favorite color?' => 'blue' }
208
+ ]
209
+ push_tasks!(TASKQUEUE_PROJECT_ID,'test', expected_tasks)
210
+
211
+ class ExtendWorker
212
+ def initialize(*args)
213
+ @stdin = args.first
214
+ end
215
+
216
+ def call(task)
217
+ $stderr.puts "ExtendWorker - task #{ task.raw.inspect }"
218
+ ttl = task.lease_remaining
219
+ # sleep( ttl - 0.5 )
220
+ task = @stdin.extend!(task)
221
+ ttl2 = task.lease_remaining
222
+ $stderr.puts "ExtendWorker - ttl before extend: #{ttl}"
223
+ $stderr.puts "ExtendWorker - ttl after extend: #{ttl2}"
224
+ end
225
+ end
226
+
227
+ # execution
228
+ app = TQ::App.new('test_app/0.0.0', ExtendWorker)
229
+ .project(TASKQUEUE_PROJECT_ID)
230
+ .stdin({ 'name' => 'test', 'num_tasks' => 1, 'lease_secs' => TASKQUEUE_LEASE_SECS + 2 })
231
+ app.run! CLIENT_SECRETS_FILE, CREDENTIALS_FILE
232
+
233
+ end
234
+
235
+ end
236
+
237
+ end
238
+
239
+ setup_test_logger!
@@ -0,0 +1,128 @@
1
+ require_relative './helper'
2
+ require_relative '../lib/tq/app'
3
+ require_relative '../lib/tq/shell'
4
+
5
+ def setup_test_logger!
6
+ TestUtils.setup_logger( File.basename(__FILE__,'.rb') )
7
+ end
8
+
9
+ class ShellTests < Minitest::Spec
10
+
11
+ include
12
+ # for installed app auth
13
+ CLIENT_SECRETS_FILE = File.expand_path(
14
+ '../config/secrets/test/client_secrets.json', File.dirname(__FILE__)
15
+ )
16
+ CREDENTIALS_FILE = File.expand_path(
17
+ "../config/secrets/test/#{File.basename(__FILE__,'.rb')}-oauth2.json",
18
+ File.dirname(__FILE__)
19
+ )
20
+
21
+ # task queue constants
22
+ TASKQUEUE_APP_CONFIG =
23
+ File.expand_path("../config/secrets/test/#{File.basename(__FILE__,'.rb')}" +
24
+ "-config.json", File.dirname(__FILE__))
25
+
26
+ TASKQUEUE_LEASE_SECS = 2
27
+
28
+ class EchoWorker
29
+
30
+ def initialize(stdin, stdout, stderr, env)
31
+ @stdin = stdin
32
+ @env = env
33
+ @logger = env['logger']
34
+ end
35
+
36
+ def call(task)
37
+ @logger.info("EchoWorker") { "Received task #{task.id}" }
38
+ @logger.debug("EchoWorker") { "Task payload: #{task.payload.inspect}" }
39
+ @logger.debug("EchoWorker") { "Env: #{@env.inspect}" }
40
+ task.finish!
41
+ end
42
+
43
+ end
44
+
45
+ def logger
46
+ @logger ||= TestUtils.current_logger
47
+ end
48
+
49
+ def queue_helper(project,queue)
50
+ TestUtils::QueueHelper.new(project,queue).auth_files(CLIENT_SECRETS_FILE, CREDENTIALS_FILE)
51
+ end
52
+
53
+ def app_config
54
+ @app_config ||= JSON.load( File.open(TASKQUEUE_APP_CONFIG) )
55
+ end
56
+
57
+ def app_project_id
58
+ app_config['project']
59
+ end
60
+
61
+ def app_stdin_name
62
+ app_config['stdin']['name']
63
+ end
64
+
65
+ def app_stdin_num_tasks
66
+ app_config['stdin']['num_tasks']
67
+ end
68
+
69
+ def populate_queue!(tasks)
70
+ q = queue_helper(app_project_id, app_stdin_name)
71
+ tasks.each do |task| q.push!(task) end
72
+ end
73
+
74
+ def clear_queue!
75
+ queue_helper(app_project_id, app_stdin_name).clear!
76
+ end
77
+
78
+ def tasks_on_queue
79
+ clear_queue!
80
+ end
81
+
82
+ def assert_tasks_on_queue(exp)
83
+ assert_equal exp, n = tasks_on_queue.length,
84
+ "Expected #{exp} tasks on input queue, was #{n}"
85
+ end
86
+
87
+ def shell_args
88
+ [ "--auth-secrets", CLIENT_SECRETS_FILE,
89
+ "--auth-store", CREDENTIALS_FILE,
90
+ "--config", TASKQUEUE_APP_CONFIG
91
+ ]
92
+ end
93
+
94
+ def setup
95
+ sleep TASKQUEUE_LEASE_SECS+1
96
+ clear_queue!
97
+ @app = TQ::App.new('test_app_shell/0.0.0', EchoWorker)
98
+ end
99
+
100
+ it 'should execute and complete app-specified number of tasks on input queue' do
101
+ exps = [
102
+ { 'What is your name?' => 'Sir Lancelot',
103
+ 'What is your quest?' => 'To seek the holy grail',
104
+ 'What is your favorite color?' => 'blue' },
105
+ { 'What is your name?' => 'Sir Robin',
106
+ 'What is your quest?' => 'To seek the holy grail',
107
+ 'What is the capital of Assyria?' => nil },
108
+ { 'What is your name?' => 'Galahad',
109
+ 'What is your quest?' => 'To seek the grail',
110
+ 'What is your favorite color?' => ['blue','yellow'] },
111
+ { 'What is your name?' => 'Arthur',
112
+ 'What is your quest?' => 'To seek the holy grail',
113
+ 'What is the air-speed velocity of an unladen swallow?' => 'African or European swallow?' }
114
+ ]
115
+
116
+ populate_queue! exps
117
+
118
+ TQ::Shell.new(@app, logger).call( shell_args )
119
+
120
+ assert_tasks_on_queue(exps.length - app_stdin_num_tasks)
121
+
122
+ end
123
+
124
+ end
125
+
126
+
127
+ setup_test_logger!
128
+
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tq
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - Eric Gjertsen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-04-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: google-api-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 0.8.4
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 0.8.4
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 5.5.1
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 5.5.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: parallel
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '='
46
+ - !ruby/object:Gem::Version
47
+ version: 1.4.1
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '='
53
+ - !ruby/object:Gem::Version
54
+ version: 1.4.1
55
+ description: Provides a simple framework for writing task worker processes
56
+ email: ericgj72@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - ".gems"
62
+ - lib/tq.rb
63
+ - lib/tq/app.rb
64
+ - lib/tq/logger.rb
65
+ - lib/tq/queue.rb
66
+ - lib/tq/shell.rb
67
+ - lib/version.rb
68
+ - test/helper.rb
69
+ - test/suite.rb
70
+ - test/test_auth.rb
71
+ - test/test_logger.rb
72
+ - test/test_run.rb
73
+ - test/test_shell.rb
74
+ homepage: https://github.com/ericgj/tq
75
+ licenses:
76
+ - MIT
77
+ metadata: {}
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubyforge_project:
94
+ rubygems_version: 2.4.5
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Ruby client for Google App Engine TaskQueue (REST API)
98
+ test_files: []