ass_launcher 0.1.1.alpha

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.
@@ -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