qbash 0.4.0 → 0.4.2

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.
data/lib/qbash.rb CHANGED
@@ -1,24 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright (c) 2024-2025 Yegor Bugayenko
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the 'Software'), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in all
13
- # copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- # SOFTWARE.
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
22
5
 
23
6
  require 'backtrace'
24
7
  require 'logger'
@@ -33,25 +16,64 @@ require 'tago'
33
16
  # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
34
17
  # License:: MIT
35
18
  module Kernel
36
- # Execute a single bash command.
19
+ # Execute a single bash command safely with proper error handling.
20
+ #
21
+ # QBash provides a safe way to execute shell commands with proper error handling,
22
+ # logging, and stdin/stdout management. It's designed to be simple for basic use
23
+ # cases while offering powerful options for advanced scenarios.
24
+ #
25
+ # == Basic Usage
26
+ #
27
+ # # Execute a command and get its output
28
+ # year = qbash('date +%Y')
29
+ # puts "Current year: #{year}"
30
+ #
31
+ # # Execute a command that might fail
32
+ # files = qbash('find /tmp -name "*.log"')
33
+ #
34
+ # == Working with Exit Codes
35
+ #
36
+ # # Get both output and exit code
37
+ # output, code = qbash('grep "error" /var/log/system.log', both: true)
38
+ # puts "Command succeeded" if code.zero?
37
39
  #
38
- # For example:
40
+ # # Accept multiple exit codes as valid
41
+ # result = qbash('grep "pattern" file.txt', accept: [0, 1])
39
42
  #
40
- # year = qbash('date +%Y')
43
+ # == Providing Input via STDIN
41
44
  #
42
- # If exit code is not zero, an exception will be raised.
45
+ # # Pass data to command's stdin
46
+ # result = qbash('wc -l', stdin: "line 1\nline 2\nline 3")
43
47
  #
44
- # To escape arguments, use +Shellwords.escape()+ method.
48
+ # == Environment Variables
45
49
  #
46
- # Stderr automatically merges with stdout.
50
+ # # Set environment variables for the command
51
+ # output = qbash('echo $NAME', env: { 'NAME' => 'Ruby' })
47
52
  #
48
- # If you need full control over the process started, provide
49
- # a block, which will receive process ID (integer) once the process
50
- # is started.
53
+ # == Logging
54
+ #
55
+ # # Enable detailed logging to stdout
56
+ # qbash('ls -la', log: $stdout)
57
+ #
58
+ # # Use custom logger with specific level
59
+ # logger = Logger.new($stdout)
60
+ # qbash('make all', log: logger, level: Logger::INFO)
61
+ #
62
+ # == Process Control
63
+ #
64
+ # # Get control over long-running process
65
+ # qbash('sleep 30') do |pid|
66
+ # puts "Process #{pid} is running..."
67
+ # # Do something while process is running
68
+ # # Process will be terminated when block exits
69
+ # end
70
+ #
71
+ # For command with multiple arguments, you can use +Shellwords.escape()+ to
72
+ # properly escape each argument. Stderr automatically merges with stdout.
51
73
  #
52
74
  # Read this <a href="https://github.com/yegor256/qbash">README</a> file for more details.
53
75
  #
54
- # @param [String] cmd The command to run, for example +echo "Hello, world!"+
76
+ # @param [String, Array] cmd The command to run (String or Array of arguments)
55
77
  # @param [String] stdin The +stdin+ to provide to the command
56
78
  # @param [Hash] env Hash of environment variables
57
79
  # @param [Loog|IO] log Logging facility with +.debug()+ method (or +$stdout+, or nil if should go to +/dev/null+)
@@ -62,82 +84,77 @@ module Kernel
62
84
  def qbash(cmd, stdin: '', env: {}, log: Loog::NULL, accept: [0], both: false, level: Logger::DEBUG)
63
85
  env.each { |k, v| raise "env[#{k}] is nil" if v.nil? }
64
86
  cmd = cmd.reject { |a| a.nil? || (a.is_a?(String) && a.empty?) }.join(' ') if cmd.is_a?(Array)
65
- mtd =
66
- case level
67
- when Logger::DEBUG
68
- :debug
69
- when Logger::INFO
70
- :info
71
- when Logger::WARN
72
- :warn
73
- when Logger::ERROR
74
- :error
75
- else
76
- raise "Unknown log level #{level}"
87
+ logit =
88
+ lambda do |msg|
89
+ mtd =
90
+ case level
91
+ when Logger::DEBUG
92
+ :debug
93
+ when Logger::INFO
94
+ :info
95
+ when Logger::WARN
96
+ :warn
97
+ when Logger::ERROR
98
+ :error
99
+ else
100
+ raise "Unknown log level #{level}"
101
+ end
102
+ if log.nil?
103
+ # nothing to print
104
+ elsif log.respond_to?(mtd)
105
+ log.__send__(mtd, msg)
106
+ else
107
+ log.print("#{msg}\n")
108
+ end
77
109
  end
78
- if log.nil?
79
- # nothing to print
80
- elsif log.respond_to?(mtd)
81
- log.__send__(mtd, "+ #{cmd}")
82
- else
83
- log.print("+ #{cmd}\n")
84
- end
110
+ logit["+ #{cmd}"]
85
111
  buf = ''
86
112
  e = 1
87
113
  start = Time.now
88
- Open3.popen2e(env, "/bin/bash -c #{Shellwords.escape(cmd)}") do |sin, sout, thr|
114
+ Open3.popen2e(env, "/bin/bash -c #{Shellwords.escape(cmd)}") do |sin, sout, ctrl|
115
+ consume =
116
+ lambda do
117
+ loop do
118
+ break if sout.eof?
119
+ ln = sout.gets
120
+ next if ln.nil?
121
+ next if ln.empty?
122
+ buf += ln
123
+ ln = "##{ctrl.pid}: #{ln}"
124
+ logit[ln]
125
+ rescue IOError => e
126
+ logit[e.message]
127
+ break
128
+ end
129
+ end
89
130
  sin.write(stdin)
90
131
  sin.close
91
132
  if block_given?
92
- closed = false
93
- watch =
94
- Thread.new do
95
- until closed
96
- begin
97
- ln = sout.gets
98
- rescue IOError => e
99
- ln = Backtrace.new(e).to_s
100
- end
101
- ln = "##{thr.pid}: #{ln}"
102
- if log.nil?
103
- # no logging
104
- elsif log.respond_to?(mtd)
105
- log.__send__(mtd, ln)
106
- else
107
- log.print(ln)
108
- end
109
- buf += ln
110
- end
111
- end
112
- pid = thr.pid
133
+ watch = Thread.new { consume.call }
134
+ watch.abort_on_exception = true
135
+ pid = ctrl.pid
113
136
  yield pid
137
+ sout.close
138
+ watch.join(0.01)
139
+ watch.kill if watch.alive?
114
140
  begin
115
- Process.kill('TERM', pid)
116
- rescue Errno::ESRCH
117
- # simply ignore it
118
- end
119
- closed = true
120
- watch.join
121
- else
122
- until sout.eof?
141
+ Process.getpgid(pid) # should be dead already (raising Errno::ESRCH)
142
+ Process.kill('TERM', pid) # let's try to kill it
123
143
  begin
124
- ln = sout.gets
125
- rescue IOError => e
126
- ln = Backtrace.new(e).to_s
127
- end
128
- if log.nil?
129
- # no logging
130
- elsif log.respond_to?(mtd)
131
- log.__send__(mtd, ln)
132
- else
133
- log.print(ln)
144
+ Process.getpgid(pid) # should be dead now (raising Errno::ESRCH)
145
+ raise "Process ##{pid} did not terminate after SIGTERM"
146
+ rescue Errno::ESRCH
147
+ logit["Process ##{pid} killed with SIGTERM"]
134
148
  end
135
- buf += ln
136
- end
137
- e = thr.value.to_i
138
- if !accept.nil? && !accept.include?(e)
139
- raise "The command '#{cmd}' failed with exit code ##{e} in #{start.ago}\n#{buf}"
149
+ rescue Errno::ESRCH
150
+ logit["Process ##{pid} exited gracefully"]
140
151
  end
152
+ else
153
+ consume.call
154
+ end
155
+ e = ctrl.value.to_i
156
+ if !accept.nil? && !accept.include?(e)
157
+ raise "The command '#{cmd}' failed with exit code ##{e} in #{start.ago}\n#{buf}"
141
158
  end
142
159
  end
143
160
  return [buf, e] if both
data/qbash.gemspec CHANGED
@@ -1,24 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright (c) 2024-2025 Yegor Bugayenko
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the 'Software'), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in all
13
- # copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- # SOFTWARE.
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
22
5
 
23
6
  require 'English'
24
7
 
@@ -26,7 +9,7 @@ Gem::Specification.new do |s|
26
9
  s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version=
27
10
  s.required_ruby_version = '>=3.2'
28
11
  s.name = 'qbash'
29
- s.version = '0.4.0'
12
+ s.version = '0.4.2'
30
13
  s.license = 'MIT'
31
14
  s.summary = 'Quick Executor of a BASH Command'
32
15
  s.description =
data/test/test__helper.rb CHANGED
@@ -1,34 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright (c) 2024-2025 Yegor Bugayenko
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the 'Software'), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in all
13
- # copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- # SOFTWARE.
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
22
5
 
23
6
  $stdout.sync = true
24
7
 
25
8
  require 'simplecov'
26
- SimpleCov.start
27
-
28
9
  require 'simplecov-cobertura'
29
- SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter
10
+ unless SimpleCov.running || ENV['PICKS']
11
+ SimpleCov.command_name('test')
12
+ SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(
13
+ [
14
+ SimpleCov::Formatter::HTMLFormatter,
15
+ SimpleCov::Formatter::CoberturaFormatter
16
+ ]
17
+ )
18
+ SimpleCov.minimum_coverage 1
19
+ SimpleCov.minimum_coverage_by_file 1
20
+ SimpleCov.start do
21
+ add_filter 'test/'
22
+ add_filter 'vendor/'
23
+ add_filter 'target/'
24
+ track_files 'lib/**/*.rb'
25
+ track_files '*.rb'
26
+ end
27
+ end
30
28
 
31
29
  require 'minitest/autorun'
32
-
33
30
  require 'minitest/reporters'
34
31
  Minitest::Reporters.use! [Minitest::Reporters::SpecReporter.new]
data/test/test_qbash.rb CHANGED
@@ -1,24 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright (c) 2024-2025 Yegor Bugayenko
4
- #
5
- # Permission is hereby granted, free of charge, to any person obtaining a copy
6
- # of this software and associated documentation files (the 'Software'), to deal
7
- # in the Software without restriction, including without limitation the rights
8
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- # copies of the Software, and to permit persons to whom the Software is
10
- # furnished to do so, subject to the following conditions:
11
- #
12
- # The above copyright notice and this permission notice shall be included in all
13
- # copies or substantial portions of the Software.
14
- #
15
- # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- # SOFTWARE.
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
22
5
 
23
6
  require 'minitest/autorun'
24
7
  require 'tmpdir'
@@ -98,7 +81,7 @@ class TestQbash < Minitest::Test
98
81
 
99
82
  def test_kills_in_thread
100
83
  Thread.new do
101
- qbash('sleep 9999') do |pid|
84
+ qbash('sleep 9999', accept: nil) do |pid|
102
85
  Process.kill('KILL', pid)
103
86
  end
104
87
  end.join
@@ -109,9 +92,10 @@ class TestQbash < Minitest::Test
109
92
  buf = Loog::Buffer.new
110
93
  Dir.mktmpdir do |home|
111
94
  flag = File.join(home, 'started.txt')
95
+ cmd = "while true; do date; touch #{Shellwords.escape(flag)}; sleep 0.001; done"
112
96
  Thread.new do
113
97
  stdout =
114
- qbash("while true; do date; touch #{Shellwords.escape(flag)}; sleep 0.001; done", log: buf) do |pid|
98
+ qbash(cmd, log: buf, accept: nil) do |pid|
115
99
  loop { break if File.exist?(flag) }
116
100
  Process.kill('KILL', pid)
117
101
  end
@@ -130,4 +114,21 @@ class TestQbash < Minitest::Test
130
114
  refute_empty(stdout)
131
115
  end
132
116
  end
117
+
118
+ def test_exists_after_background_stop
119
+ stop = false
120
+ t =
121
+ Thread.new do
122
+ qbash('trap "" TERM; tail -f /dev/null', accept: nil) do
123
+ loop { break if stop }
124
+ end
125
+ end
126
+ t.abort_on_exception = true
127
+ sleep(0.1)
128
+ stop = true
129
+ t.join(0.01)
130
+ t.kill
131
+ sleep(0.01)
132
+ refute_predicate(t, :alive?)
133
+ end
133
134
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qbash
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-02-07 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: backtrace
@@ -72,8 +71,8 @@ email: yegor256@gmail.com
72
71
  executables: []
73
72
  extensions: []
74
73
  extra_rdoc_files:
75
- - README.md
76
74
  - LICENSE.txt
75
+ - README.md
77
76
  files:
78
77
  - ".0pdd.yml"
79
78
  - ".gitattributes"
@@ -83,17 +82,20 @@ files:
83
82
  - ".github/workflows/markdown-lint.yml"
84
83
  - ".github/workflows/pdd.yml"
85
84
  - ".github/workflows/rake.yml"
85
+ - ".github/workflows/reuse.yml"
86
+ - ".github/workflows/typos.yml"
86
87
  - ".github/workflows/xcop.yml"
87
88
  - ".github/workflows/yamllint.yml"
88
89
  - ".gitignore"
89
90
  - ".pdd"
90
91
  - ".rubocop.yml"
91
92
  - ".rultor.yml"
92
- - ".simplecov"
93
93
  - Gemfile
94
94
  - Gemfile.lock
95
95
  - LICENSE.txt
96
+ - LICENSES/MIT.txt
96
97
  - README.md
98
+ - REUSE.toml
97
99
  - Rakefile
98
100
  - lib/qbash.rb
99
101
  - qbash.gemspec
@@ -105,7 +107,6 @@ licenses:
105
107
  - MIT
106
108
  metadata:
107
109
  rubygems_mfa_required: 'true'
108
- post_install_message:
109
110
  rdoc_options:
110
111
  - "--charset=UTF-8"
111
112
  require_paths:
@@ -121,8 +122,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
121
122
  - !ruby/object:Gem::Version
122
123
  version: '0'
123
124
  requirements: []
124
- rubygems_version: 3.4.10
125
- signing_key:
125
+ rubygems_version: 3.6.7
126
126
  specification_version: 4
127
127
  summary: Quick Executor of a BASH Command
128
128
  test_files: []
data/.simplecov DELETED
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- #
4
- # Copyright (c) 2024-2025 Yegor Bugayenko
5
- #
6
- # Permission is hereby granted, free of charge, to any person obtaining a copy
7
- # of this software and associated documentation files (the 'Software'), to deal
8
- # in the Software without restriction, including without limitation the rights
9
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- # copies of the Software, and to permit persons to whom the Software is
11
- # furnished to do so, subject to the following conditions:
12
- #
13
- # The above copyright notice and this permission notice shall be included in all
14
- # copies or substantial portions of the Software.
15
- #
16
- # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- # SOFTWARE.
23
-
24
- if Gem.win_platform?
25
- SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
26
- SimpleCov::Formatter::HTMLFormatter
27
- ]
28
- SimpleCov.start do
29
- add_filter '/test/'
30
- add_filter '/features/'
31
- end
32
- else
33
- SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(
34
- [SimpleCov::Formatter::HTMLFormatter]
35
- )
36
- SimpleCov.start do
37
- add_filter '/test/'
38
- add_filter '/features/'
39
- minimum_coverage 10
40
- end
41
- end