ass_launcher 0.1.1.alpha

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,212 @@
1
+ # encoding: utf-8
2
+ module AssLauncher
3
+ module Support
4
+ module Shell
5
+ # Class for running {Command} in subprocess and controlling
6
+ # him.
7
+ # Process run command in the thread. Thread waiting for process exit
8
+ # and call {Command#exit_handling}
9
+ # @example
10
+ # # Run command and witing for exit
11
+ # ph = ProcessHolder.run(command, options)
12
+ #
13
+ # result = ph.wait.result
14
+ # raise 'bang!' unless result.sucsess?
15
+ #
16
+ # # Run command and kill process when nidet
17
+ #
18
+ # ph = ProcessHolder.run(command, options)
19
+ #
20
+ # sleep 10 # for wakeup command
21
+ #
22
+ # if ! ph.alive?
23
+ # raise 'command onexpected exit'
24
+ # end
25
+ #
26
+ # # doing something
27
+ #
28
+ # ph.kill
29
+ #
30
+ # # run and wait command
31
+ #
32
+ # ph = ProcessHolder.run(command, options).wait
33
+ #
34
+ # raise if ph.result.success?
35
+ #
36
+ #
37
+ # @note WARNIG!!! not forgot kill of threads created and handled of
38
+ # ProcessHolder
39
+ #
40
+ # @note For run command used popen3 whith command_string and *args.
41
+ # It not shell running. If command.args.size == 0 in command.args array
42
+ # will be pushed one empty string.
43
+ # For more info see Process.spawn documentation
44
+ # @api private
45
+ class ProcessHolder
46
+ require 'open3'
47
+ require 'ass_launcher/support/platforms'
48
+ include Support::Platforms
49
+ class KillProcessError < StandardError; end
50
+ class RunProcessError < StandardError; end
51
+ class ProcessNotRunning < StandardError; end
52
+ # @api public
53
+ # @return [Fixnum] pid of runned process
54
+ attr_reader :pid
55
+
56
+ # @api public
57
+ # @return [RunAssResult] result of execution command
58
+ attr_reader :result
59
+
60
+ # @api public
61
+ # @return [Command, Script] command runned in process
62
+ attr_reader :command
63
+
64
+ # @api private
65
+ # @return [Thread] thread waiting for process
66
+ attr_reader :thread
67
+
68
+ # @api private
69
+ attr_reader :options, :popen3_thread
70
+
71
+ Thread.abort_on_exception = true
72
+
73
+ # Keep of created instaces
74
+ # @return [Arry<ProcessHolder>]
75
+ # @api public
76
+ def self.process_list
77
+ @@process_slist ||= []
78
+ end
79
+
80
+ def self.unreg_process(h)
81
+ process_list.delete(h)
82
+ end
83
+ private_class_method :unreg_process
84
+
85
+ def self.reg_process(h)
86
+ process_list << h
87
+ end
88
+ private_class_method :reg_process
89
+
90
+ # @note 'cmd /K command` not exit when exit command. Thread hangup
91
+ # @api private
92
+ def self.cmd_exe_with_k?(command)
93
+ shell_str = "#{command.cmd} #{command.args.join(' ')}"
94
+ ! (shell_str =~ %r{(?<=\W|\A)cmd(.exe)?\s*(\/K)}i).nil?
95
+ end
96
+
97
+ # @note 'cmd /C command` not kill command when cmd killed
98
+ # @api private
99
+ def self.cmd_exe_with_c?(command)
100
+ shell_str = "#{command.cmd} #{command.args.join(' ')}"
101
+ ! (shell_str =~ %r{(?<=\W|\A)cmd(.exe)?\s*(\/C)}i).nil?
102
+ end
103
+
104
+ # Run command subprocess in new Thread and return instace for process
105
+ # controlling
106
+ # Thread wait process and handling process exit wihth
107
+ # {Command#exit_handling}
108
+ # @param command [Command, Script] command runned in subprocess
109
+ # @param options [Hash] options for +Process.spawn+
110
+ # @return [ProcessHolder] instance with runned command
111
+ # @note (see ProcessHolder)
112
+ # @raise [RunProcessError] if command is cmd.exe with /K key see
113
+ # {cmd_exe_with_k?}
114
+ # @api public
115
+ # @raise (see initialize)
116
+ def self.run(command, options = {})
117
+ fail RunProcessError, 'Forbidden run cmd.exe with /K key'\
118
+ if cmd_exe_with_k? command
119
+ h = new(command, options)
120
+ reg_process h
121
+ h.run
122
+ end
123
+
124
+ # @param (see run)
125
+ # @raise [ArgumentError] if command was already running
126
+ def initialize(command, options = {})
127
+ fail ArgumentError, 'Command was already running' if command.running?
128
+ @command = command
129
+ command.send(:process_holder=, self)
130
+ @options = options
131
+ options[:new_pgroup] = true if windows?
132
+ end
133
+
134
+ # @return [self]
135
+ # @raise [RunProcessError] if process was already running
136
+ def run
137
+ fail RunProcessError, "Process was run. Pid: #{pid}" if running?
138
+ @popen3_thread, stdout, stderr = run_process
139
+ @pid = @popen3_thread.pid
140
+ @thread = wait_process_in_thread(stdout, stderr)
141
+ self
142
+ end
143
+
144
+ def running?
145
+ ! pid.nil?
146
+ end
147
+
148
+ def wait_process_in_thread(stdout, stderr)
149
+ Thread.new do
150
+ popen3_thread.join
151
+ begin
152
+ @result = command.exit_handling(exitstatus,\
153
+ stdout.read,\
154
+ stderr.read)
155
+ rescue StandardError => e
156
+ @result = e
157
+ end
158
+ self.class.send(:unreg_process, self)
159
+ end
160
+ end
161
+ private :wait_process_in_thread
162
+
163
+ def exitstatus
164
+ popen3_thread.value.to_i
165
+ end
166
+ private :exitstatus
167
+
168
+ # Run new process
169
+ def run_process
170
+ command.args << '' if command.args.size == 0
171
+ _r1, r2, r3, thread = Open3.popen3 command.cmd, *command.args, options
172
+ [thread, r2, r3]
173
+ end
174
+ private :run_process
175
+
176
+ # Kill the process
177
+ # @return [self]
178
+ # @note WARNIG! for command runned as cmd /C commnd can't get pid of
179
+ # command process. In this case error raised
180
+ # @raise [KillProcessError] if command is cmd.exe with /C key see
181
+ # {cmd_exe_with_c?}
182
+ # @api public
183
+ # @raise (see alive?)
184
+ def kill
185
+ return self unless alive?
186
+ fail KillProcessError, 'Can\'t kill subprocess runned in cmd.exe '\
187
+ 'on the windows machine' if self.class.cmd_exe_with_c? command
188
+ Process.kill('KILL', pid)
189
+ wait
190
+ end
191
+
192
+ # Wait for thread exit
193
+ # @return [self]
194
+ # @api public
195
+ # @raise (see alive?)
196
+ def wait
197
+ return self unless alive?
198
+ thread.join
199
+ self
200
+ end
201
+
202
+ # True if thread alive
203
+ # @api public
204
+ # @raise [ProcessNotRunning] unless process running
205
+ def alive?
206
+ fail ProcessNotRunning unless running?
207
+ thread.alive?
208
+ end
209
+ end # ProcessHolder
210
+ end # Shell
211
+ end # Support
212
+ end # AssLauncher
@@ -0,0 +1,374 @@
1
+ # encoding: utf-8
2
+
3
+ # Monkey patch for [String]
4
+ class String
5
+ require 'shellwords'
6
+ def to_cmd
7
+ if AssLauncher::Support::Platforms.windows?\
8
+ || AssLauncher::Support::Platforms.cygwin?
9
+ "\"#{self}\""
10
+ else
11
+ escape
12
+ end
13
+ end
14
+
15
+ def escape
16
+ Shellwords.escape self
17
+ end
18
+ end
19
+
20
+ #
21
+ module AssLauncher
22
+ class << self
23
+ def config
24
+ @config ||= Configuration.new
25
+ end
26
+ end
27
+
28
+ def self.configure
29
+ yield(config)
30
+ end
31
+
32
+ # Configuration for {AssLauncher}
33
+ class Configuration
34
+ attr_accessor :logger
35
+
36
+ def initialize
37
+ @logger = Loggining.default_logger
38
+ end
39
+
40
+ def logger=(l)
41
+ fail ArgumentError, 'Logger may be valid logger' if l.nil?
42
+ @logger = l
43
+ end
44
+ end
45
+ # Loggining mixin
46
+ module Loggining
47
+ require 'logger'
48
+
49
+ DEFAULT_LEVEL = Logger::Severity::UNKNOWN
50
+
51
+ def self.included(k)
52
+ k.extend(self)
53
+ end
54
+
55
+ def logger
56
+ AssLauncher.config.logger
57
+ end
58
+
59
+ # @api private
60
+ def self.default_logger
61
+ l = Logger.new($stderr)
62
+ l.level = DEFAULT_LEVEL
63
+ l
64
+ end
65
+ end
66
+ module Support
67
+ # Shell utils for run 1C:Enterprise binary
68
+ module Shell
69
+ # TODO: delete it see todo in platform #cygpath func
70
+ class RunError < StandardError; end
71
+ require 'methadone'
72
+ require 'tempfile'
73
+ require 'ass_launcher/support/shell/process_holder'
74
+ include Loggining
75
+ include Methadone::SH
76
+ extend Support::Platforms
77
+
78
+ # Command running directly as:
79
+ # popen3(command.cmd, *command.args, options)
80
+ #
81
+ # @note What reason for it? Reason for it:
82
+ #
83
+ # Fucking 1C binary often unexpected parse cmd arguments if run in
84
+ # shell like `1c.exe arguments`. For correction this invented two way run
85
+ # 1C binary: as command see {Shell::Command} or as script
86
+ # see {Shell::Script}. If run 1C as command we can control executing
87
+ # process wait exit or kill 1C binary process. If run 1C as script 1C
88
+ # more correctly parse arguments but we can't kill subprosess running
89
+ # in cmd.exe
90
+ #
91
+ # @note On default use silient execute 1C binary whit
92
+ # /DisableStartupDialogs,
93
+ # /DisableStartupMessages parameters and capture 1C output /OUT
94
+ # parameter. Read message from /OUT when 1C binary process exit and
95
+ # build instnce of RunAssResult.
96
+ #
97
+ # @note (see AssOutFile)
98
+ # @api private
99
+ class Command
100
+ attr_reader :cmd, :args, :ass_out_file, :options
101
+ attr_accessor :process_holder
102
+ private :process_holder=
103
+ private :ass_out_file
104
+ DEFAULT_OPTIONS = { silent_mode: true,
105
+ capture_assout: true
106
+ }
107
+ # @param cmd [String] path to 1C binary
108
+ # @param args [Array] arguments for 1C binary
109
+ # @option options [String] :assout_encoding encoding for assoutput file.
110
+ # Default 'cp1251'
111
+ # @option options [Boolean] :capture_assout capture assoutput.
112
+ # Default true
113
+ # @option options [Boolean]:silent_mode run 1C with
114
+ # /DisableStartupDialogs and /DisableStartupMessages parameters.
115
+ # Default true
116
+ def initialize(cmd, args = [], options = {})
117
+ @options = DEFAULT_OPTIONS.merge(options).freeze
118
+ @cmd = cmd
119
+ @args = args
120
+ @args += _silent_mode
121
+ @ass_out_file = _ass_out_file
122
+ end
123
+
124
+ # @return [true] if command was already running
125
+ def running?
126
+ ! process_holder.nil?
127
+ end
128
+
129
+ # Run command
130
+ # @param options [Hash] options for Process.spawn
131
+ # @return [ProcessHolder]
132
+ def run(options = {})
133
+ return process_holder if running?
134
+ ProcessHolder.run(self, options)
135
+ end
136
+
137
+ def _silent_mode
138
+ if options[:silent_mode]
139
+ ['/DisableStartupDialogs', '',
140
+ '/DisableStartupMessages', '']
141
+ else
142
+ []
143
+ end
144
+ end
145
+ private :_silent_mode
146
+
147
+ def _out_ass_argument(out_file)
148
+ @args += ['/OUT', out_file.to_s]
149
+ out_file
150
+ end
151
+ private :_out_ass_argument
152
+
153
+ def _ass_out_file
154
+ if options[:capture_assout]
155
+ out_file = AssOutFile.new(options[:assout_encoding])
156
+ _out_ass_argument out_file
157
+ else
158
+ StringIO.new
159
+ end
160
+ end
161
+ private :_ass_out_file
162
+
163
+ def to_s
164
+ "#{cmd} #{args.join(' ')}"
165
+ end
166
+
167
+ def exit_handling(exitstatus, out, err)
168
+ RunAssResult.new(exitstatus, encode_out(out),
169
+ encode_out(err), ass_out_file.read)
170
+ end
171
+
172
+ def encode_out(out)
173
+ out
174
+ end
175
+ private :encode_out
176
+ end
177
+
178
+ # class {Script} wraping cmd string in to script tempfile and running as:
179
+ # popen3('cmd.exe', '/C', 'tempfile' in cygwin or windows
180
+ # or popen3('sh', 'tempfile') in linux
181
+ #
182
+ # @note (see Command)
183
+ # @api private
184
+ class Script < Command
185
+ include Support::Platforms
186
+ # @param cmd [String] cmd string for executing as cmd.exe or sh script
187
+ # @option (see Command#initialize)
188
+ def initialize(cmd, options = {})
189
+ super cmd, [], options
190
+ end
191
+
192
+ def make_script
193
+ @file = Tempfile.new(%w( run_ass_script .cmd ))
194
+ @file.open
195
+ @file.write(encode)
196
+ @file.close
197
+ platform.path(@file.path)
198
+ end
199
+ private :make_script
200
+
201
+ # @note used @args variable for reason!
202
+ # In class {Script} methods {Script#cmd} and
203
+ # {Script#args} returns command and args for run
204
+ # script in sh or cmd.exe but @rgs varible use in {#to_s} for
205
+ # generate script content
206
+ # script
207
+ def _out_ass_argument(out_file)
208
+ @args += ['/OUT', "\"#{out_file}\""]
209
+ out_file
210
+ end
211
+ private :_out_ass_argument
212
+
213
+ def encode
214
+ if cygwin_or_windows?
215
+ # TODO: need to detect current win cmd encoding cp866 - may be wrong
216
+ return to_s.encode('cp866', 'utf-8')
217
+ end
218
+ to_s
219
+ end
220
+ private :encode
221
+
222
+ # @note used @cmd and @args variable for reason!
223
+ # In class {Script} methods {Script#cmd} and
224
+ # {Script#args} returns command and args for run
225
+ # script in sh or cmd.exe but {#to_s} return content for
226
+ # script
227
+ def to_s
228
+ "#{@cmd} #{@args.join(' ')}"
229
+ end
230
+
231
+ def cygwin_or_windows?
232
+ cygwin? || windows?
233
+ end
234
+ private :cygwin_or_windows?
235
+
236
+ # Returm shell binary 'cmd.exe' or 'sh'
237
+ # @return [String]
238
+ def cmd
239
+ if cygwin_or_windows?
240
+ 'cmd.exe'
241
+ else
242
+ 'sh'
243
+ end
244
+ end
245
+
246
+ # Return args for run shell script
247
+ # @return [Array]
248
+ def args
249
+ if cygwin_or_windows?
250
+ ['/C', make_script.win_string]
251
+ else
252
+ [make_script.to_s]
253
+ end.freeze
254
+ end
255
+
256
+ def encode_out(out)
257
+ # TODO: need to detect current win cmd encoding cp866 - may be wrong
258
+ begin
259
+ out.encode!('utf-8', 'cp866') if cygwin_or_windows?
260
+ rescue EncodingError => e
261
+ return "#{e.class}: #{out}"
262
+ end
263
+ out
264
+ end
265
+ private :encode_out
266
+
267
+ # Run script. Script wait process exit
268
+ # @param options [Hash] options for Process.spawn
269
+ # @return [ProcessHolder]
270
+ def run(options = {})
271
+ ph = super
272
+ ph.wait
273
+ end
274
+ end
275
+
276
+ # Contain result for execute 1C binary
277
+ # see {ProcessHolder#result}
278
+ # @api private
279
+ class RunAssResult
280
+ class UnexpectedAssOut < StandardError; end
281
+ class RunAssError < StandardError; end
282
+ attr_reader :out, :assout, :exitstatus, :err
283
+ attr_accessor :expected_assout
284
+ def initialize(exitstatus, out, err, assout)
285
+ @err = err
286
+ @out = out
287
+ @exitstatus = exitstatus
288
+ @assout = assout
289
+ end
290
+
291
+ # Verivfy of result and raises unless {#success?}
292
+ # @raise [UnexpectedAssOut] - exitstatus == 0 but taken unexpected
293
+ # assout {!#expected_assout?}
294
+ # @raise [RunAssError] - if other errors taken
295
+ # @api public
296
+ def verify!
297
+ fail UnexpectedAssOut, cut_assout unless expected_assout?
298
+ fail RunAssError, "#{err}#{cut_assout}" unless success?
299
+ self
300
+ end
301
+
302
+ def cut_assout
303
+ return assout if assout.size <= 80
304
+ "#{assout[0, 80]}..."
305
+ end
306
+ private :cut_assout
307
+
308
+ # @api public
309
+ def success?
310
+ exitstatus == 0 && expected_assout?
311
+ end
312
+
313
+ # Set regex for verify assout
314
+ # @note (see #expected_assout?)
315
+ # @param exp [nil, Regexp]
316
+ # @raise [ArgumentError] when bad expresion given
317
+ # @api public
318
+ def expected_assout=(exp)
319
+ return if exp.nil?
320
+ fail ArgumentError unless exp.is_a? Regexp
321
+ @expected_assout = exp
322
+ end
323
+
324
+ # @note Sometimes 1C does what we not expects. For example, we ask
325
+ # to create InfoBase File="tmp\tmp.ib" however 1C make files of
326
+ # infobase in root of 'tmp\' directory and exits with status 0. In this
327
+ # case we have to check assout for answer executed success? or not.
328
+ # Checkin {#assout} string
329
+ # If existstatus != 0 checking assout value skiped and return true
330
+ # It work when exitstatus == 0 but taken unexpected assout
331
+ # @return [Boolean]
332
+ # @api public
333
+ def expected_assout?
334
+ return true if expected_assout.nil?
335
+ return true if exitstatus != 0
336
+ ! (expected_assout =~ assout).nil?
337
+ end
338
+ end
339
+
340
+ # Hold, read and encode 1C output
341
+ #
342
+ # @note Fucking 1C not work with stdout and stderr
343
+ # For out 1C use /OUT"file" parameter and write message into. Message
344
+ # encoding 'cp1251' for windows and 'utf-8' for Linux
345
+ # @api private
346
+ class AssOutFile
347
+ include Support::Platforms
348
+ attr_reader :file, :path, :encoding
349
+ def initialize(encoding = nil)
350
+ @file = Tempfile.new('ass_out')
351
+ @file.close
352
+ @path = platform.path(@file.path)
353
+ @encoding = encoding || Encoding::CP1251
354
+ end
355
+
356
+ def to_s
357
+ @path.to_s
358
+ end
359
+
360
+ def read
361
+ begin
362
+ @file.open
363
+ s = @file.read
364
+ s.encode! Encoding::UTF_8, encoding unless linux?
365
+ ensure
366
+ @file.close
367
+ @file.unlink
368
+ end
369
+ s.to_s
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,66 @@
1
+ module AssLauncher
2
+ # Common objects and methods
3
+ module Support
4
+ EOF = "\r\n"
5
+ BOM = "\xEF\xBB\xBF".force_encoding('utf-8')
6
+
7
+ # v8i file reader-writer
8
+ module V8iFile
9
+ require 'inifile'
10
+ class ReadError < StandardError; end
11
+
12
+ # Read v8i content and return array of v8i sections
13
+ # @param io [IO] the input starem opened for read
14
+ # @return [Array<V8iSection>]
15
+ def self.read(io)
16
+ res = []
17
+ inifile = to_inifile(io.read)
18
+ inifile.each_section do |caption|
19
+ res << V8iSection.new(caption, inifile[caption])
20
+ end
21
+ res
22
+ end
23
+
24
+ # Read v8i file
25
+ # @param filename [String]
26
+ # @return (see read)
27
+ # @raise [ReadError] if file not exists
28
+ def self.load(filename)
29
+ fail ReadError, "File #{filename} not exist or not a file"\
30
+ unless File.file? filename
31
+ read File.new(filename, 'r:bom|utf-8')
32
+ end
33
+
34
+ # Write sections in to output stream
35
+ # @param io [IO] the output stream open for writing
36
+ # @param sections [Array<V8iSection>] sections for write
37
+ def self.write(io, sections)
38
+ sections.each do |s|
39
+ io.write(s.to_s + "\r\n")
40
+ end
41
+ end
42
+
43
+ # Save sections in to v8i file
44
+ # @param filename [String]
45
+ # @param sections (see write)
46
+ def self.save(filename, sections)
47
+ write File.new(filename, 'w'), sections
48
+ end
49
+
50
+ private
51
+
52
+ def self.to_inifile(content)
53
+ IniFile.new(content: "#{escape_content(content)}", comment: '')
54
+ end
55
+
56
+ def self.escape_content(content)
57
+ content.gsub!(BOM, '')
58
+ content.gsub!(/\\\\/, '\\\\\\\\\\')
59
+ %w(r n t 0).each do |l|
60
+ content.gsub!(/\\#{l}/, "\\\\\\#{l}")
61
+ end
62
+ content
63
+ end
64
+ end
65
+ end
66
+ end