chutzen 0.8.0 → 0.9.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e078b40512684bbdd04c341199d45ed740749194fa8a1154cc3760afdd9cefeb
4
- data.tar.gz: 2eca64fa5545fc9c7722ab06fd5456458b31e7347248bb67f30237616c1c24e7
3
+ metadata.gz: d5150871b332694272fd6cd676916cc8b710c13e7f4f47cdfd717b0b5517a398
4
+ data.tar.gz: b47c1877522ef2082e3d507a03c735a817906cd751da307aaa94bf81ae678972
5
5
  SHA512:
6
- metadata.gz: 2f53fd4ea333c556f30143fc580b8b5c0fb9d2450bf093c29e9c60f97147836c280b6b999ed33c13ff1c807004bb7b132f994fea0881d4ee4f3730300a974cd8
7
- data.tar.gz: 943189ce50907777c0d663e4c755277dabe35fbbeadcded292c53dd657b0a766f55aff5d88dc3cee6b47e94bf932694b0849f88fac1fa02c43e6f905c95548c5
6
+ metadata.gz: 56cbd867e92a2f6bd9e0cc3ce366e84099f7f5f82b1a46f3126c578692c1af764ccecb96156e93fa893975e6e3aad46d34f3c729166da53939faa9fe9525c5ec
7
+ data.tar.gz: a597dfd4348eddf963d3e815fdb6734a6f545a9c01180524cfff41593fd0e18cdea5f1de0bf5170fc9d3e854c793e81181bff526611e3d523c19066450b0e239
data/README.md CHANGED
@@ -139,6 +139,10 @@ When *execute* contain a string it will be passed to be executed verbatim. An Ar
139
139
  The *fail_when* section can contain any of the following options:
140
140
 
141
141
  * read_timeout: Check if the stdout or stderr grows and kill the command if this doesn't happen for the specified number of seconds.
142
+ * ~~runtime: Check the runtime of the command and kill it when it runs for more than the specified number of seconds.~~
143
+ * select_timeout: Read and runtime checking use select on the process output. The timeout is normally computed as `[read_timeout, runtime].compact.min / 2.0` but you can force a different value if necessary.
144
+
145
+ > The runtime option is currently accepted, but not enforced. This may return in the future.
142
146
 
143
147
  ### Merge description
144
148
 
@@ -229,7 +233,7 @@ Now we can schedule the `Chutzen::Signal` worker through the `emit_signal` conve
229
233
 
230
234
  Downside of using a job is that you can enqueue multiple signals which may keep stopping Chutzen.
231
235
 
232
- 20.times { Chutzen.emit_signal('stop', queue: 'chutzen-13') }
236
+ 20.times { Chutzen.emit_signal('stop', queue: 'chutzen-13') }
233
237
 
234
238
  That is why a `Chutzen::Signal` worker will, by default, only process within 30 seconds after is was enqueued. The expiration time can be expressed in a number of ways if you don't like the default.
235
239
 
@@ -239,6 +243,38 @@ That is why a `Chutzen::Signal` worker will, by default, only process within 30
239
243
  set(queue: 'chutzen-5-test').
240
244
  perform_async('stop', (Time.now + 10).to_i)
241
245
 
242
- ## New Relic integrations
243
-
244
- Chutzen can send custom metrics and exceptions to New Relic. The New Relic gem is ‘dynamically’ loaded so you have to make sure it's installed for this to work. You also have to provide a valid `confg/newrelic.yml` file relative to the working directory where Chutzen is started.
246
+ ## Mocking
247
+
248
+ Chutzen descriptions can get relatively complex and you may want to use a mock to test logic. You can do this by mocking commands:
249
+
250
+ Chutzen.mocked(
251
+ 'ls' => lamda do
252
+ # Generally you only access stdout and stderr from mocks and return
253
+ # the exit status code.
254
+ stdout.puts("app test")
255
+ 0
256
+ end,
257
+ 'unknown' => lambda do
258
+ # The mock uses the basepath of the executed path to look up the
259
+ # Proc. So /path/to/unknown becomes unknown. Use the 127 exit status
260
+ # to simulate a missing binary.
261
+ 127
262
+ end,
263
+ # The value returned from the Proc is cast to an integer, so this is
264
+ # zero.
265
+ 'zero' => -> {},
266
+ # You have full access to the Chutzen::Command internals, but these
267
+ # mocks may break when internals change.
268
+ 'hack' => lambda do
269
+ @dictionary['width'] = '201'
270
+ 0
271
+ end,
272
+ ) do
273
+ # Any command executed in the same process/thread will now use the mocked
274
+ # commands instead of using Open3 to execute it.
275
+ Command.new(
276
+ { "execute" => "ls" },
277
+ dictionary:,
278
+ work_path:
279
+ ).perform
280
+ end
@@ -6,7 +6,7 @@ require 'stringio'
6
6
  module Chutzen
7
7
  # Holds a description for a command and executes it.
8
8
  class Command
9
- ALLOW_EVAL = /\A[\d<>=\s]+\z/.freeze
9
+ ALLOW_EVAL = /\A[\d<>=\s]+\z/
10
10
 
11
11
  autoload :ExecutionFailed, 'chutzen/command/execution_failed'
12
12
 
@@ -58,11 +58,19 @@ module Chutzen
58
58
  def perform
59
59
  return if skip?
60
60
 
61
- result = @execute ? perform_command : self
61
+ result = @execute ? perform_command_or_mock : self
62
62
  merge_remember_into_dictionary
63
63
  result
64
64
  end
65
65
 
66
+ def perform_command_or_mock
67
+ if Chutzen.mock
68
+ perform_mock
69
+ else
70
+ perform_command
71
+ end
72
+ end
73
+
66
74
  def perform_command
67
75
  trace_execution do
68
76
  # We use the STOP signal to instruct Sidekiq to stop reading from the queue and the TERM
@@ -87,6 +95,13 @@ module Chutzen
87
95
  end
88
96
  end
89
97
 
98
+ def perform_mock
99
+ @exit_status = ExitStatus.new(Chutzen.mock.perform(self).to_i)
100
+ verify_exit_status
101
+ merge_output_into_dictionary
102
+ self
103
+ end
104
+
90
105
  # Returns the result expressed by the result section but with its variables
91
106
  # instantiated.
92
107
  def result
@@ -137,18 +152,17 @@ module Chutzen
137
152
  end
138
153
 
139
154
  def monitor(stdout, stderr, thread)
140
- watcher = Watcher.new(fail_when: @fail_when, files: [@stdout, @stderr])
141
- wait_until_done(
142
- Demux.new(
143
- stdout, $stdout, @stdout, select_timeout: watcher.select_timeout
144
- ),
145
- Demux.new(
146
- stderr, $stderr, @stderr, select_timeout: watcher.select_timeout
147
- ),
148
- watcher
149
- )
150
- rescue Chutzen::Watcher::Error => e
151
- Process.kill('KILL', thread.pid)
155
+ fail_when = FailWhen.new(@fail_when)
156
+ [
157
+ Demux.thread(stdout, $stdout, @stdout, select_timeout: fail_when.select_timeout),
158
+ Demux.thread(stderr, $stderr, @stderr, select_timeout: fail_when.select_timeout)
159
+ ].each(&:join)
160
+ rescue Chutzen::Demux::Error => e
161
+ begin
162
+ Process.kill('KILL', thread.pid)
163
+ rescue Errno::ESRCH
164
+ # Means the process doesn't exist so it already stopped.
165
+ end
152
166
  raise ExecutionFailed.new(e.message, command: self) unless optional?
153
167
  end
154
168
 
@@ -198,19 +212,9 @@ module Chutzen
198
212
  end
199
213
  end
200
214
 
201
- def trace_execution(&block)
202
- if defined?(::NewRelic)
203
- NewRelic::Agent::MethodTracer.trace_execution_scoped(
204
- transaction_scope,
205
- &block
206
- )
207
- else
208
- yield
209
- end
210
- end
211
-
212
- def transaction_scope
213
- %W[Custom/Command/#{name}]
215
+ # Placeholder for plugging an exception handler.
216
+ def trace_execution
217
+ yield
214
218
  end
215
219
  end
216
220
  end
data/lib/chutzen/demux.rb CHANGED
@@ -8,28 +8,38 @@ module Chutzen
8
8
  # demux.tick
9
9
  # end
10
10
  class Demux
11
- BUFFER_SIZE = 1024
12
-
13
- def initialize(input, *output, select_timeout: nil)
14
- @input = input
15
- @output = output
16
- @select_timeout = select_timeout
17
- @done = false
11
+ class Error < StandardError
18
12
  end
19
13
 
20
- def done?
21
- @done
22
- end
14
+ BUFFER_SIZE = 4096
23
15
 
24
- def tick
25
- readable = IO.select([@input], nil, nil, @select_timeout)
26
- return unless readable
16
+ def self.thread(...)
17
+ thread = Thread.new { copy(...) }
18
+ thread.abort_on_exception = true
19
+ thread
20
+ end
27
21
 
28
- buffer = readable.first.first.read_nonblock(BUFFER_SIZE)
29
- @done = buffer.nil?
30
- @output.each { |stream| stream.write(buffer) }
31
- rescue IOError
32
- @done = true
22
+ # rubocop:disable Metrics/MethodLength
23
+ def self.copy(input, *output, select_timeout: nil)
24
+ done = false
25
+ until done
26
+ result = input.read_nonblock(BUFFER_SIZE, exception: false)
27
+ case result
28
+ when String
29
+ output.each do |stream|
30
+ stream.write(result)
31
+ stream.flush
32
+ end
33
+ when :wait_readable
34
+ IO.select([input], nil, nil, select_timeout)
35
+ when nil
36
+ # End of file.
37
+ else
38
+ raise Error, "Demuxer encountered unsupported read_nonblock return value `#{result}'"
39
+ end
40
+ done = input.eof?
41
+ end
33
42
  end
43
+ # rubocop:enable Metrics/MethodLength
34
44
  end
35
45
  end
@@ -82,7 +82,7 @@ module Chutzen
82
82
  else
83
83
  data.each do |name, value|
84
84
  path = search(value, key)
85
- return (path << name) if path
85
+ return path << name if path
86
86
  end
87
87
  nil
88
88
  end
@@ -91,7 +91,7 @@ module Chutzen
91
91
  def self.search_array(data, key)
92
92
  data.each.with_index do |item, index|
93
93
  path = search(item, key)
94
- return (path << index) if path
94
+ return path << index if path
95
95
  end
96
96
  nil
97
97
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module Chutzen
6
+ # Used to mock an exit status normally provided by executing something through Open3.
7
+ ExitStatus = Data.define(:status) do
8
+ def to_i
9
+ status.to_i
10
+ end
11
+
12
+ def success?
13
+ status.zero?
14
+ end
15
+ end
16
+ end
@@ -9,7 +9,7 @@ module Chutzen
9
9
  autoload :SyntaxError, 'chutzen/expression/syntax_error'
10
10
  autoload :LookupError, 'chutzen/expression/lookup_error'
11
11
 
12
- INTEGER_RE = /\A\d+\z/.freeze
12
+ INTEGER_RE = /\A\d+\z/
13
13
  OPERATIONS = {
14
14
  '=' => ->(left, right) { left == right },
15
15
  '&' => ->(left, right) { left & right },
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ # Value class to contain details about when to fail a command.
5
+ #
6
+ # FailWhen.new(
7
+ # {
8
+ # 'read_timeout' => 2,
9
+ # 'runtime' => 60
10
+ # }
11
+ # )
12
+ class FailWhen
13
+ attr_reader :read_timeout, :runtime
14
+
15
+ def initialize(fail_when = nil)
16
+ fail_when&.each do |name, value|
17
+ instance_variable_set("@#{name}", value)
18
+ end
19
+ end
20
+
21
+ def select_timeout
22
+ return @select_timeout if defined?(@select_timeout)
23
+
24
+ minimal_timeout = [read_timeout, runtime].compact.min
25
+ return unless minimal_timeout
26
+
27
+ minimal_timeout / 2.0
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chutzen
4
+ # Keeps mocked commands and executes Command instances against them.
5
+ class Mock
6
+ attr_reader :mocks
7
+
8
+ def initialize(mocks)
9
+ @mocks = mocks
10
+ end
11
+
12
+ def perform(command)
13
+ name = File.basename(command.to_s.split(' ', 2).first.to_s)
14
+ command.instance_exec(&mocks.fetch(name))
15
+ end
16
+ end
17
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chutzen
4
- VERSION = '0.8.0'
4
+ VERSION = '0.9.0'
5
5
  end
data/lib/chutzen.rb CHANGED
@@ -3,23 +3,18 @@
3
3
  require 'json'
4
4
  require 'sidekiq/api'
5
5
 
6
- # Disabled to allow optional loading of the New Relic gem.
7
- # rubocop:disable Lint/SuppressedException
8
- begin
9
- require 'newrelic_rpm'
10
- rescue LoadError
11
- end
12
- # rubocop:enable Lint/SuppressedException
13
-
14
6
  # Implements a toolkit to perform batch processing.
15
7
  module Chutzen
16
8
  autoload :Apply, 'chutzen/apply'
17
9
  autoload :Command, 'chutzen/command'
18
10
  autoload :Demux, 'chutzen/demux'
19
11
  autoload :Dictionary, 'chutzen/dictionary'
12
+ autoload :ExitStatus, 'chutzen/exit_status'
20
13
  autoload :Expression, 'chutzen/expression'
21
14
  autoload :ExpressionParser, 'chutzen/expression_parser'
15
+ autoload :FailWhen, 'chutzen/fail_when'
22
16
  autoload :Job, 'chutzen/job'
17
+ autoload :Mock, 'chutzen/mock'
23
18
  autoload :Notification, 'chutzen/notification'
24
19
  autoload :RuntimeError, 'chutzen/runtime_error'
25
20
  autoload :Shell, 'chutzen/shell'
@@ -28,18 +23,29 @@ module Chutzen
28
23
  autoload :Template, 'chutzen/template'
29
24
  autoload :TemplateParser, 'chutzen/template_parser'
30
25
  autoload :VERSION, 'chutzen/version'
31
- autoload :Watcher, 'chutzen/watcher'
32
26
  autoload :Worker, 'chutzen/worker'
33
27
 
34
28
  class << self
35
- attr_accessor :sidekiq_queue
29
+ attr_accessor :sidekiq_queue, :mock
36
30
  end
37
31
  self.sidekiq_queue = 'chutzen'
32
+ self.mock = nil
38
33
 
39
34
  # Enqueue a Sidekiq job that will eventually perform the job in the
40
35
  # description.
41
- def self.perform_async(description)
42
- Chutzen::Worker.perform_async(JSON.dump(description))
36
+ #
37
+ # * +queue+: Override +Chutzen.sidekiq_queue+.
38
+ def self.perform_async(description, queue: nil)
39
+ async_worker(queue: queue).perform_async(JSON.dump(description))
40
+ end
41
+
42
+ # Configures a worker class for asynchronous processing.
43
+ #
44
+ # * +queue+: Override +Chutzen.sidekiq_queue+.
45
+ def self.async_worker(queue: nil)
46
+ worker = Chutzen::Worker
47
+ worker = worker.set(queue: queue) if queue
48
+ worker
43
49
  end
44
50
 
45
51
  # Enqueue a Sidekiq job that will eventually process the signal. It's
@@ -63,8 +69,11 @@ module Chutzen
63
69
  end
64
70
 
65
71
  # Dequeue all jobs that match the query. Uses Chutzen's own query format.
66
- def self.dequeue(query)
67
- Sidekiq::Queue.new(sidekiq_queue).each do |job|
72
+ #
73
+ # * +queue+: Override +Chutzen.sidekiq_queue+.
74
+ def self.dequeue(query, queue: nil)
75
+ queue ||= sidekiq_queue
76
+ Sidekiq::Queue.new(queue).each do |job|
68
77
  dictionary = Chutzen::Dictionary.new(JSON.parse(job.args.first))
69
78
  expression = Chutzen::Expression.new(query, dictionary)
70
79
  job.delete if expression.result
@@ -84,4 +93,12 @@ module Chutzen
84
93
  expires_in ||= 30
85
94
  (Time.now + expires_in).to_i
86
95
  end
96
+
97
+ # Sets the global mock instance for the duration of the block.
98
+ def self.mocked(**)
99
+ self.mock = Chutzen::Mock.new(**)
100
+ yield
101
+ ensure
102
+ self.mock = nil
103
+ end
87
104
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chutzen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manfred Stienstra
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-02 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: parslet
@@ -30,28 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '6.0'
33
+ version: '7.0'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '6.0'
41
- - !ruby/object:Gem::Dependency
42
- name: newrelic_rpm
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">"
46
- - !ruby/object:Gem::Version
47
- version: '6.10'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">"
53
- - !ruby/object:Gem::Version
54
- version: '6.10'
40
+ version: '7.0'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: minitest
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -95,11 +81,14 @@ files:
95
81
  - lib/chutzen/command/execution_failed.rb
96
82
  - lib/chutzen/demux.rb
97
83
  - lib/chutzen/dictionary.rb
84
+ - lib/chutzen/exit_status.rb
98
85
  - lib/chutzen/expression.rb
99
86
  - lib/chutzen/expression/lookup_error.rb
100
87
  - lib/chutzen/expression/syntax_error.rb
101
88
  - lib/chutzen/expression_parser.rb
89
+ - lib/chutzen/fail_when.rb
102
90
  - lib/chutzen/job.rb
91
+ - lib/chutzen/mock.rb
103
92
  - lib/chutzen/notification.rb
104
93
  - lib/chutzen/runtime_error.rb
105
94
  - lib/chutzen/shell.rb
@@ -109,7 +98,6 @@ files:
109
98
  - lib/chutzen/template/syntax_error.rb
110
99
  - lib/chutzen/template_parser.rb
111
100
  - lib/chutzen/version.rb
112
- - lib/chutzen/watcher.rb
113
101
  - lib/chutzen/worker.rb
114
102
  homepage:
115
103
  licenses: []
@@ -122,14 +110,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
122
110
  requirements:
123
111
  - - ">="
124
112
  - !ruby/object:Gem::Version
125
- version: '2.7'
113
+ version: '3.2'
126
114
  required_rubygems_version: !ruby/object:Gem::Requirement
127
115
  requirements:
128
116
  - - ">="
129
117
  - !ruby/object:Gem::Version
130
118
  version: '0'
131
119
  requirements: []
132
- rubygems_version: 3.3.7
120
+ rubygems_version: 3.5.22
133
121
  signing_key:
134
122
  specification_version: 4
135
123
  summary: Toolkit to implement batch processing.
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Chutzen
4
- # Watches the current process to make sure it's still running as intended.
5
- #
6
- # You can set a watcher to make sure a list of files increases in size at
7
- # least every ‘read_timeout’ seconds.
8
- #
9
- # watcher = Watcher.new(
10
- # fail_when: {
11
- # 'read_timeout' => 2,
12
- # 'runtime' => 60
13
- # },
14
- # files: [file]
15
- # )
16
- # until watcher.done?
17
- # watcher.tick
18
- # end
19
- class Watcher
20
- class Error < StandardError
21
- end
22
-
23
- attr_reader :read_timeout, :runtime
24
-
25
- def initialize(fail_when: nil, files: nil)
26
- fail_when&.each do |name, value|
27
- instance_variable_set("@#{name}", value)
28
- end
29
- @started_at = now
30
- @files = files
31
- reset
32
- end
33
-
34
- def select_timeout
35
- return @select_timeout if defined?(@select_timeout)
36
-
37
- minimal_timeout = [read_timeout, runtime].compact.min
38
- return unless minimal_timeout
39
-
40
- minimal_timeout / 2.0
41
- end
42
-
43
- def done?
44
- # Because demuxers and the watcher share an interface we need to the
45
- # watcher to explain the thread doesn't have to wait for it.
46
- true
47
- end
48
-
49
- # Raises an exception when any of the failure conditions is reached.
50
- def tick
51
- compute_last_read
52
- verify_last_read
53
- verify_runtime
54
- end
55
-
56
- private
57
-
58
- def total_bytes_read
59
- return unless @files
60
-
61
- @files.inject(0) { |total, file| total + file.size }
62
- end
63
-
64
- def last_read_ago
65
- now - @last_read_at
66
- end
67
-
68
- def compute_last_read
69
- return unless read_timeout && @files
70
-
71
- bytes_read = total_bytes_read
72
- @last_read_at = now if bytes_read != @bytes_read
73
- @bytes_read = bytes_read
74
- end
75
-
76
- def verify_last_read
77
- return unless read_timeout && @files
78
- return if last_read_ago < read_timeout
79
-
80
- raise(
81
- Error,
82
- "Command did not write to its output for more than #{read_timeout} " \
83
- 'seconds.'
84
- )
85
- end
86
-
87
- def ran
88
- now - @started_at
89
- end
90
-
91
- def verify_runtime
92
- return unless runtime
93
- return if ran < runtime
94
-
95
- raise(
96
- Error,
97
- "Command did not finish within the alotted runtime #{runtime} " \
98
- 'seconds.'
99
- )
100
- end
101
-
102
- def reset
103
- @last_read_at = now
104
- @bytes_read = 0
105
- end
106
-
107
- def now
108
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
- end
110
- end
111
- end