cmds 0.0.3 → 0.0.4

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,239 @@
1
+ class Cmds
2
+ # stream inputs and/or outputs
3
+ #
4
+ # originally inspired by
5
+ #
6
+ # https://nickcharlton.net/posts/ruby-subprocesses-with-stdout-stderr-streams.html
7
+ #
8
+ # with major modifications from looking at Ruby's open3 module.
9
+ #
10
+ def stream *subs, &input_block
11
+ Cmds.debug "entering Cmds#stream",
12
+ subs: subs,
13
+ input_block: input_block
14
+
15
+ # use `merge_options` to get the args and kwds (we will take custom
16
+ # care of input in _stream)
17
+ options = merge_options subs, nil
18
+
19
+ # build the command string
20
+ cmd = Cmds.sub @template, options[:args], options[:kwds]
21
+
22
+ # call the internal function
23
+ really_stream cmd, options, &input_block
24
+ end
25
+
26
+ private
27
+
28
+ # do the actual work...
29
+ def really_stream cmd, options, &input_block
30
+ Cmds.debug "entering Cmds#really_stream",
31
+ cmd: cmd,
32
+ options: options,
33
+ input_block: input_block
34
+
35
+ # create the handler that will be yielded to the input block
36
+ handler = IOHandler.new
37
+
38
+ # handle input
39
+
40
+ # default to the instance variable
41
+ input = @input
42
+
43
+ # if a block was provided, it might provide overriding input
44
+ if input_block
45
+ case input_block.arity
46
+ when 0
47
+ # when the input block takes no arguments it returns the input
48
+ input = input_block.call
49
+ when 1
50
+ # when the input block takes one argument, give it the handler and
51
+ # ignore the return value
52
+ input_block.call handler
53
+
54
+ # if input was assigned to the handler in the block, use it as input
55
+ input = handler.in unless handler.in.nil?
56
+ else
57
+ # bad block provided
58
+ raise ArgumentError.new NRSER.squish <<-BLOCK
59
+ provided input block must have arity 0 or 1
60
+ BLOCK
61
+ end # case input.arity
62
+ end # if input_block
63
+
64
+ # hash of options that will be passed to `spawn`
65
+ spawn_opts = {}
66
+
67
+ Cmds.debug "looking at input...",
68
+ input: input
69
+
70
+ # (possibly) create the input pipe... this will be nil if the provided
71
+ # input is io-like. in this case it will be used directly in the
72
+ # `spawn` options.
73
+ in_pipe = case input
74
+ when nil, String
75
+ Cmds.debug "input is a String or nil, creating pipe..."
76
+
77
+ in_pipe = Cmds::Pipe.new "INPUT", :in
78
+ spawn_opts[:in] = in_pipe.r
79
+
80
+ # don't buffer input
81
+ in_pipe.w.sync = true
82
+ in_pipe
83
+
84
+ else
85
+ Cmds.debug "input should be io-like, setting spawn opt.",
86
+ input: input
87
+ if input == $stdin
88
+ Cmds.debug "input is $stdin."
89
+ end
90
+ spawn_opts[:in] = input
91
+ nil
92
+
93
+ end # case input
94
+
95
+ # (possibly) create the output pipes.
96
+ #
97
+ # `stream` can be told to send it's output to either:
98
+ #
99
+ # 1. a Proc that will invoked with each line.
100
+ # 2. an io-like object that can be provided as `spawn`'s `:out` or
101
+ # `:err` options.
102
+ #
103
+ # in case (1) a `Cmds::Pipe` wrapping read and write piped `IO` instances
104
+ # will be created and assigned to the relevant of `out_pipe` or `err_pipe`.
105
+ #
106
+ # in case (2) the io-like object will be sent directly to `spawn` and
107
+ # the relevant `out_pipe` or `err_pipe` will be `nil`.
108
+ #
109
+ out_pipe, err_pipe = [
110
+ ["ERROR", :err],
111
+ ["OUTPUT", :out],
112
+ ].map do |name, sym|
113
+ Cmds.debug "looking at #{ name }..."
114
+ # see if hanlder.out or hanlder.err is a Proc
115
+ if handler.send(sym).is_a? Proc
116
+ Cmds.debug "#{ name } is a Proc, creating pipe..."
117
+ pipe = Cmds::Pipe.new name, sym
118
+ # the corresponding :out or :err option for spawn needs to be
119
+ # the pipe's write handle
120
+ spawn_opts[sym] = pipe.w
121
+ # return the pipe
122
+ pipe
123
+
124
+ else
125
+ Cmds.debug "#{ name } should be io-like, setting spawn opt.",
126
+ output: handler.send(sym)
127
+ spawn_opts[sym] = handler.send(sym)
128
+ # the pipe is nil!
129
+ nil
130
+ end
131
+ end # map outputs
132
+
133
+ Cmds.debug "spawning...",
134
+ cmd: cmd,
135
+ opts: spawn_opts
136
+
137
+ pid = spawn cmd, spawn_opts
138
+
139
+ Cmds.debug "spawned.",
140
+ pid: pid
141
+
142
+ wait_thread = Process.detach pid
143
+ wait_thread[:name] = "WAIT"
144
+
145
+ Cmds.debug "wait thread created.",
146
+ thread: wait_thread
147
+
148
+ # close child ios if created
149
+ # the spawned process will read from in_pipe.r so we don't need it
150
+ in_pipe.r.close if in_pipe
151
+ # and we don't need to write to the output pipes, that will also happen
152
+ # in the spawned process
153
+ [out_pipe, err_pipe].each {|pipe| pipe.w.close if pipe}
154
+
155
+ # create threads to handle any pipes that were created
156
+
157
+ in_thread = if in_pipe
158
+ Thread.new do
159
+ Thread.current[:name] = in_pipe.name
160
+ Cmds.debug "thread started, writing input..."
161
+
162
+ in_pipe.w.write input unless input.nil?
163
+
164
+ Cmds.debug "write done, closing in_pipe.w..."
165
+ in_pipe.w.close
166
+
167
+ Cmds.debug "thread done."
168
+ end # Thread
169
+ end
170
+
171
+ out_thread, err_thread = [out_pipe, err_pipe].map do |pipe|
172
+ if pipe
173
+ Thread.new do
174
+ Thread.current[:name] = pipe.name
175
+ Cmds.debug "thread started"
176
+
177
+ loop do
178
+ Cmds.debug "blocking on gets..."
179
+ line = pipe.r.gets
180
+ if line.nil?
181
+ Cmds.debug "received nil, output done."
182
+ else
183
+ Cmds.debug NRSER.squish <<-BLOCK
184
+ received #{ line.bytesize } bytes, passing to handler.
185
+ BLOCK
186
+ end
187
+ handler.thread_send_line pipe.sym, line
188
+ break if line.nil?
189
+ end
190
+
191
+ Cmds.debug "reading done, closing pipe.r (unless already closed)..."
192
+ pipe.r.close unless pipe.r.closed?
193
+
194
+ Cmds.debug "thread done."
195
+ end # thread
196
+ end # if pipe
197
+ end # map threads
198
+
199
+ Cmds.debug "handing off main thread control to the handler..."
200
+ begin
201
+ handler.start
202
+
203
+ Cmds.debug "handler done."
204
+
205
+ ensure
206
+ # wait for the threads to complete
207
+ Cmds.debug "joining threads..."
208
+
209
+ [in_thread, out_thread, err_thread, wait_thread].each do |thread|
210
+ if thread
211
+ Cmds.debug "joining #{ thread[:name] } thread..."
212
+ thread.join
213
+ end
214
+ end
215
+
216
+ Cmds.debug "all threads done."
217
+ end
218
+
219
+ status = wait_thread.value.exitstatus
220
+ Cmds.debug "exit status: #{ status.inspect }"
221
+
222
+ Cmds.debug "checking @assert and exit status..."
223
+ if @assert && status != 0
224
+ # we don't necessarily have the err output, so we can't include it
225
+ # in the error message
226
+ msg = NRSER.squish <<-BLOCK
227
+ streamed command `#{ cmd }` exited with status #{ status }
228
+ BLOCK
229
+
230
+ raise SystemCallError.new msg, status
231
+ end
232
+
233
+ Cmds.debug "streaming completed."
234
+
235
+ return status
236
+ end #really_stream
237
+
238
+ # end private
239
+ end
data/lib/cmds/sugar.rb ADDED
@@ -0,0 +1,76 @@
1
+ # convenience methods
2
+
3
+ # global methods
4
+ # ==============
5
+
6
+ # proxies to `Cmds::capture`
7
+ def Cmds *args, &block
8
+ Cmds.capture *args, &block
9
+ end
10
+
11
+ # proxies to `Cmds::ok?`
12
+ def Cmds? *args, &block
13
+ Cmds.ok? *args, &block
14
+ end
15
+
16
+ # proxies to `Cmds::assert`
17
+ def Cmds! *args, &block
18
+ Cmds.assert *args, &block
19
+ end
20
+
21
+ class Cmds
22
+ # class methods
23
+ # =============
24
+
25
+ # create a new Cmd from template and subs and call it
26
+ def self.capture template, *subs, &input_block
27
+ new(template, options(subs, input_block)).capture
28
+ end
29
+
30
+ def self.ok? template, *subs, &input_block
31
+ new(template, options(subs, input_block)).ok?
32
+ end
33
+
34
+ def self.error? template, *subs, &input_block
35
+ new(template, options(subs, input_block)).error?
36
+ end
37
+
38
+ def self.assert template, *subs, &input_block
39
+ new(
40
+ template,
41
+ options(subs, input_block).merge!(assert: true)
42
+ ).capture
43
+ end
44
+
45
+ def self.stream template, *subs, &input_block
46
+ Cmds.new(template).stream *subs, &input_block
47
+ end
48
+
49
+ def self.stream! template, *subs, &input_block
50
+ Cmds.new(template, assert: true).stream *subs, &input_block
51
+ end # ::stream!
52
+
53
+ # instance methods
54
+ # ================
55
+
56
+ alias_method :call, :capture
57
+
58
+ def ok?
59
+ stream == 0
60
+ end
61
+
62
+ def error?
63
+ stream != 0
64
+ end
65
+
66
+ # def assert
67
+ # capture.raise_error
68
+ # end
69
+
70
+ def proxy
71
+ stream do |io|
72
+ io.in = $stdin
73
+ end
74
+ end
75
+
76
+ end # class Cmds
data/lib/cmds/util.rb ADDED
@@ -0,0 +1,254 @@
1
+ # util functions
2
+ class Cmds
3
+ # class methods
4
+ # =============
5
+
6
+ # shortcut for Shellwords.escape
7
+ #
8
+ # also makes it easier to change or customize or whatever
9
+ def self.esc str
10
+ Shellwords.escape str
11
+ end
12
+
13
+ # escape option hash.
14
+ #
15
+ # this is only useful for the two common option styles:
16
+ #
17
+ # - single character keys become `-<char> <value>`
18
+ #
19
+ # {x: 1} => "-x 1"
20
+ #
21
+ # - longer keys become `--<key>=<value>` options
22
+ #
23
+ # {blah: 2} => "--blah=2"
24
+ #
25
+ # if you have something else, you're going to have to just put it in
26
+ # the cmd itself, like:
27
+ #
28
+ # Cmds "blah -assholeOptionOn:%{s}", "ok"
29
+ #
30
+ # or whatever similar shit said command requires.
31
+ #
32
+ # however, if the value is an Array, it will repeat the option for each
33
+ # value:
34
+ #
35
+ # {x: [1, 2, 3]} => "-x 1 -x 2 -x 3"
36
+ # {blah: [1, 2, 3]} => "--blah=1 --blah=2 --blah=3"
37
+ #
38
+ # i can't think of any right now, but i swear i've seen commands that take
39
+ # opts that way.
40
+ #
41
+ def self.expand_option_hash hash
42
+ hash.map {|key, values|
43
+ # keys need to be strings
44
+ key = key.to_s unless key.is_a? String
45
+
46
+ [key, values]
47
+
48
+ }.sort {|(key_a, values_a), (key_b, values_b)|
49
+ # sort by the (now string) keys
50
+ key_a <=> key_b
51
+
52
+ }.map {|key, values|
53
+ # for simplicity's sake, treat all values like an array
54
+ values = [values] unless values.is_a? Array
55
+
56
+ # keys of length 1 expand to `-x v` form
57
+ expanded = if key.length == 1
58
+ values.map {|value|
59
+ if value.nil?
60
+ "-#{ esc key }"
61
+ else
62
+ "-#{ esc key } #{ esc value}"
63
+ end
64
+ }
65
+
66
+ # longer keys expand to `--key=value` form
67
+ else
68
+ values.map {|value|
69
+ if value.nil?
70
+ "--#{ esc key }"
71
+ else
72
+ "--#{ esc key }=#{ esc value }"
73
+ end
74
+ }
75
+ end
76
+ }.flatten.join ' '
77
+ end # ::expand_option_hash
78
+
79
+ # expand one of the substitutions
80
+ def self.expand_sub sub
81
+ case sub
82
+ when nil
83
+ # nil is just an empty string, NOT an empty string bash token
84
+ ''
85
+ when Hash
86
+ expand_option_hash sub
87
+ else
88
+ esc sub.to_s
89
+ end
90
+ end # ::expand_sub
91
+
92
+ # substitute values into a command, escaping them for the shell and
93
+ # offering convenient expansions for some structures.
94
+ #
95
+ # `cmd` is a string that can be substituted via ruby's `%` operator, like
96
+ #
97
+ # "git diff %s"
98
+ #
99
+ # for positional substitution, or
100
+ #
101
+ # "git diff %{path}"
102
+ #
103
+ # for keyword substitution.
104
+ #
105
+ # `subs` is either:
106
+ #
107
+ # - an Array when `cmd` has positional placeholders
108
+ # - a Hash when `cmd` has keyword placeholders.
109
+ #
110
+ # the elements of the `subs` array or values of the `subs` hash are:
111
+ #
112
+ # - strings that are substituted into `cmd` after being escaped:
113
+ #
114
+ # sub "git diff %{path}", path: "some path/to somewhere"
115
+ # # => 'git diff some\ path/to\ somewhere'
116
+ #
117
+ # - hashes that are expanded into options:
118
+ #
119
+ # sub "psql %{opts} %{database} < %{filepath}",
120
+ # database: "blah",
121
+ # filepath: "/where ever/it/is.psql",
122
+ # opts: {
123
+ # username: "bingo bob",
124
+ # host: "localhost",
125
+ # port: 12345,
126
+ # }
127
+ # # => 'psql --host=localhost --port=12345 --username=bingo\ bob blah < /where\ ever/it/is.psql'
128
+ #
129
+ def self.sub cmd, args = [], kwds = {}
130
+ raise TypeError.new("args must be an Array") unless args.is_a? Array
131
+ raise TypeError.new("kwds must be an Hash") unless kwds.is_a? Hash
132
+
133
+ context = ERBContext.new(args, kwds)
134
+ erb = ShellEruby.new(replace_shortcuts cmd)
135
+
136
+ NRSER.squish erb.result(context.get_binding)
137
+ end # ::sub
138
+
139
+ def self.options subs, input_block
140
+ args = []
141
+ kwds = {}
142
+ input = input_block.nil? ? nil : input_block.call
143
+
144
+ case subs.length
145
+ when 0
146
+ # nothing to do
147
+ when 1
148
+ # can either be a hash, which is interpreted as a keywords,
149
+ # or an array, which is interpreted as positional arguments
150
+ case subs[0]
151
+ when Hash
152
+ kwds = subs[0]
153
+
154
+ when Array
155
+ args = subs[0]
156
+
157
+ else
158
+ raise TypeError.new NRSER.squish <<-BLOCK
159
+ first *subs arg must be Array or Hash, not #{ subs[0].inspect }
160
+ BLOCK
161
+ end
162
+
163
+ when 2
164
+ # first arg needs to be an array, second a hash
165
+ unless subs[0].is_a? Array
166
+ raise TypeError.new NRSER.squish <<-BLOCK
167
+ first *subs arg needs to be an array, not #{ subs[0].inspect }
168
+ BLOCK
169
+ end
170
+
171
+ unless subs[1].is_a? Hash
172
+ raise TypeError.new NRSER.squish <<-BLOCK
173
+ second *subs arg needs to be a Hash, not #{ subs[1].inspect }
174
+ BLOCK
175
+ end
176
+
177
+ args, kwds = subs
178
+ else
179
+ raise ArgumentError.new NRSER.squish <<-BLOCK
180
+ must provide one or two *subs arguments, received #{ 1 + subs.length }
181
+ BLOCK
182
+ end
183
+
184
+ return {
185
+ args: args,
186
+ kwds: kwds,
187
+ input: input,
188
+ }
189
+ end # ::options
190
+
191
+ def self.replace_shortcuts template
192
+ template
193
+ .gsub(
194
+ # %s => <%= arg %>
195
+ /(?<=\A|[[:space:]])\%s(?=\Z|[[:space:]])/,
196
+ '<%= arg %>'
197
+ )
198
+ .gsub(
199
+ # %%s => %s (escpaing)
200
+ /(?<=\A|[[:space:]])(\%+)\%s(?=\Z|[[:space:]])/,
201
+ '\1s'
202
+ )
203
+ .gsub(
204
+ # %{key} => <%= key %>, %{key?} => <%= key? %>
205
+ /(?<=\A|[[:space:]])\%\{([a-zA-Z_]+\??)\}(?=\Z|[[:space:]])/,
206
+ '<%= \1 %>'
207
+ )
208
+ .gsub(
209
+ # %%{key} => %{key}, %%{key?} => %{key?} (escpaing)
210
+ /(?<=\A|[[:space:]])(\%+)\%\{([a-zA-Z_]+\??)\}(?=\Z|[[:space:]])/,
211
+ '\1{\2}\3'
212
+ )
213
+ .gsub(
214
+ # %<key>s => <%= key %>, %<key?>s => <%= key? %>
215
+ /(?<=\A|[[:space:]])\%\<([a-zA-Z_]+\??)\>s(?=\Z|[[:space:]])/,
216
+ '<%= \1 %>'
217
+ )
218
+ .gsub(
219
+ # %%<key>s => %<key>s, %%<key?>s => %<key?>s (escaping)
220
+ /(?<=\A|[[:space:]])(\%+)\%\<([a-zA-Z_]+\??)\>s(?=\Z|[[:space:]])/,
221
+ '\1<\2>s'
222
+ )
223
+ end # ::replace_shortcuts
224
+
225
+ # instance methods
226
+ # ================
227
+
228
+ # returns a new `Cmds` with the subs and input block merged in
229
+ def curry *subs, &input_block
230
+ self.class.new @template, merge_options(subs, input_block)
231
+ end
232
+
233
+ private
234
+
235
+ # merges options already present on the object with options
236
+ # provided via subs and input_block and returns a new options
237
+ # Hash
238
+ def merge_options subs, input_block
239
+ # get the options present in the arguments
240
+ options = Cmds.options subs, input_block
241
+ # the new args are created by appending the provided args to the
242
+ # existing ones
243
+ options[:args] = @args + options[:args]
244
+ # the new kwds are created by merging the provided kwds into the
245
+ # exising ones (new values override previous)
246
+ options[:kwds] = @kwds.merge options[:kwds]
247
+ # if there is input present via the provided block, it is used.
248
+ # otherwise, previous input is used, which may be `nil`
249
+ options[:input] ||= @input
250
+ return options
251
+ end # #merge_options
252
+
253
+ # end private
254
+ end # class Cmds
data/lib/cmds/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Cmds
2
- VERSION = "0.0.3"
2
+ VERSION = "0.0.4"
3
3
  end