taski 0.5.0 → 0.7.1
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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +168 -21
- data/docs/GUIDE.md +394 -0
- data/examples/README.md +65 -17
- data/examples/{context_demo.rb → args_demo.rb} +27 -27
- data/examples/clean_demo.rb +204 -0
- data/examples/data_pipeline_demo.rb +1 -1
- data/examples/group_demo.rb +113 -0
- data/examples/large_tree_demo.rb +519 -0
- data/examples/reexecution_demo.rb +93 -80
- data/examples/simple_progress_demo.rb +80 -0
- data/examples/system_call_demo.rb +56 -0
- data/lib/taski/{context.rb → args.rb} +3 -3
- data/lib/taski/execution/base_progress_display.rb +348 -0
- data/lib/taski/execution/execution_context.rb +383 -0
- data/lib/taski/execution/executor.rb +405 -134
- data/lib/taski/execution/plain_progress_display.rb +76 -0
- data/lib/taski/execution/registry.rb +17 -1
- data/lib/taski/execution/scheduler.rb +308 -0
- data/lib/taski/execution/simple_progress_display.rb +173 -0
- data/lib/taski/execution/task_output_pipe.rb +42 -0
- data/lib/taski/execution/task_output_router.rb +287 -0
- data/lib/taski/execution/task_wrapper.rb +215 -52
- data/lib/taski/execution/tree_progress_display.rb +349 -212
- data/lib/taski/execution/worker_pool.rb +104 -0
- data/lib/taski/section.rb +16 -3
- data/lib/taski/static_analysis/visitor.rb +3 -0
- data/lib/taski/task.rb +218 -37
- data/lib/taski/test_helper/errors.rb +13 -0
- data/lib/taski/test_helper/minitest.rb +38 -0
- data/lib/taski/test_helper/mock_registry.rb +51 -0
- data/lib/taski/test_helper/mock_wrapper.rb +46 -0
- data/lib/taski/test_helper/rspec.rb +38 -0
- data/lib/taski/test_helper.rb +214 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +211 -23
- data/sig/taski.rbs +207 -27
- metadata +25 -8
- data/docs/advanced-features.md +0 -625
- data/docs/api-guide.md +0 -509
- data/docs/error-handling.md +0 -684
- data/examples/section_progress_demo.rb +0 -78
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Taski
|
|
6
|
+
module Execution
|
|
7
|
+
# WorkerPool manages a pool of worker threads that execute tasks.
|
|
8
|
+
# It provides methods to start, stop, and enqueue tasks for execution.
|
|
9
|
+
#
|
|
10
|
+
# == Responsibilities
|
|
11
|
+
#
|
|
12
|
+
# - Manage worker thread lifecycle (start, shutdown)
|
|
13
|
+
# - Distribute tasks to worker threads via Queue
|
|
14
|
+
# - Execute tasks via callback provided by Executor
|
|
15
|
+
#
|
|
16
|
+
# == API
|
|
17
|
+
#
|
|
18
|
+
# - {#start} - Start all worker threads
|
|
19
|
+
# - {#enqueue} - Add a task to the execution queue
|
|
20
|
+
# - {#shutdown} - Gracefully shutdown all worker threads
|
|
21
|
+
# - {#execution_queue} - Access the underlying Queue (for testing)
|
|
22
|
+
#
|
|
23
|
+
# == Thread Safety
|
|
24
|
+
#
|
|
25
|
+
# WorkerPool uses Queue for thread-safe task distribution.
|
|
26
|
+
# The Queue handles synchronization between the main thread
|
|
27
|
+
# (which enqueues tasks) and worker threads (which pop tasks).
|
|
28
|
+
class WorkerPool
|
|
29
|
+
attr_reader :execution_queue
|
|
30
|
+
|
|
31
|
+
# @param registry [Registry] The task registry for thread tracking
|
|
32
|
+
# @param worker_count [Integer, nil] Number of worker threads (defaults to CPU count)
|
|
33
|
+
# @param on_execute [Proc] Callback to execute a task, receives (task_class, wrapper)
|
|
34
|
+
def initialize(registry:, worker_count: nil, &on_execute)
|
|
35
|
+
@worker_count = worker_count || default_worker_count
|
|
36
|
+
@registry = registry
|
|
37
|
+
@on_execute = on_execute
|
|
38
|
+
@execution_queue = Queue.new
|
|
39
|
+
@workers = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Start all worker threads.
|
|
43
|
+
def start
|
|
44
|
+
@worker_count.times do
|
|
45
|
+
worker = Thread.new { worker_loop }
|
|
46
|
+
@workers << worker
|
|
47
|
+
@registry.register_thread(worker)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Enqueue a task for execution.
|
|
52
|
+
#
|
|
53
|
+
# @param task_class [Class] The task class to execute
|
|
54
|
+
# @param wrapper [TaskWrapper] The task wrapper
|
|
55
|
+
def enqueue(task_class, wrapper)
|
|
56
|
+
@execution_queue.push({task_class: task_class, wrapper: wrapper})
|
|
57
|
+
debug_log("Enqueued: #{task_class}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Shutdown all worker threads gracefully.
|
|
61
|
+
def shutdown
|
|
62
|
+
enqueue_shutdown_signals
|
|
63
|
+
@workers.each(&:join)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Enqueue shutdown signals for all workers.
|
|
67
|
+
def enqueue_shutdown_signals
|
|
68
|
+
@worker_count.times { @execution_queue.push(:shutdown) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def default_worker_count
|
|
74
|
+
Etc.nprocessors.clamp(2, 8)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def worker_loop
|
|
78
|
+
loop do
|
|
79
|
+
work_item = @execution_queue.pop
|
|
80
|
+
break if work_item == :shutdown
|
|
81
|
+
|
|
82
|
+
task_class = work_item[:task_class]
|
|
83
|
+
wrapper = work_item[:wrapper]
|
|
84
|
+
|
|
85
|
+
debug_log("Worker executing: #{task_class}")
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
@on_execute.call(task_class, wrapper)
|
|
89
|
+
rescue => e
|
|
90
|
+
# Log error but don't crash the worker thread.
|
|
91
|
+
# Task-level errors are handled in the execute callback.
|
|
92
|
+
# This catches unexpected errors in the callback itself.
|
|
93
|
+
warn "[WorkerPool] Unexpected error executing #{task_class}: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def debug_log(message)
|
|
99
|
+
return unless ENV["TASKI_DEBUG"]
|
|
100
|
+
puts "[WorkerPool] #{message}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/taski/section.rb
CHANGED
|
@@ -24,6 +24,9 @@ module Taski
|
|
|
24
24
|
raise "Section #{self.class} does not have an implementation. Override 'impl' method."
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Register runtime dependency for clean phase (before register_impl_selection)
|
|
28
|
+
register_runtime_dependency(implementation_class)
|
|
29
|
+
|
|
27
30
|
# Register selected impl for progress display
|
|
28
31
|
register_impl_selection(implementation_class)
|
|
29
32
|
|
|
@@ -43,11 +46,21 @@ module Taski
|
|
|
43
46
|
|
|
44
47
|
private
|
|
45
48
|
|
|
49
|
+
# Register the selected implementation as a runtime dependency.
|
|
50
|
+
# This allows the clean phase to include the dynamically selected impl.
|
|
51
|
+
# Handles nil ExecutionContext gracefully.
|
|
52
|
+
#
|
|
53
|
+
# @param impl_class [Class] The selected implementation class
|
|
54
|
+
def register_runtime_dependency(impl_class)
|
|
55
|
+
context = Execution::ExecutionContext.current
|
|
56
|
+
context&.register_runtime_dependency(self.class, impl_class)
|
|
57
|
+
end
|
|
58
|
+
|
|
46
59
|
def register_impl_selection(implementation_class)
|
|
47
|
-
|
|
48
|
-
return unless
|
|
60
|
+
context = Execution::ExecutionContext.current
|
|
61
|
+
return unless context
|
|
49
62
|
|
|
50
|
-
|
|
63
|
+
context.notify_section_impl_selected(self.class, implementation_class)
|
|
51
64
|
end
|
|
52
65
|
|
|
53
66
|
# @param implementation_class [Class] The implementation task class
|
|
@@ -92,6 +92,9 @@ module Taski
|
|
|
92
92
|
# Re-analyze the class methods
|
|
93
93
|
# Preserve impl chain context: methods called from impl should continue
|
|
94
94
|
# detecting constants as impl candidates
|
|
95
|
+
# Set namespace path from target class name for constant resolution
|
|
96
|
+
@current_namespace_path = @target_task_class.name.split("::")
|
|
97
|
+
|
|
95
98
|
@class_method_defs.each do |method_name, method_node|
|
|
96
99
|
next unless new_methods.include?(method_name)
|
|
97
100
|
|
data/lib/taski/task.rb
CHANGED
|
@@ -5,8 +5,34 @@ require_relative "execution/registry"
|
|
|
5
5
|
require_relative "execution/task_wrapper"
|
|
6
6
|
|
|
7
7
|
module Taski
|
|
8
|
+
# Base class for all tasks in the Taski framework.
|
|
9
|
+
# Tasks define units of work with dependencies and exported values.
|
|
10
|
+
#
|
|
11
|
+
# @example Defining a simple task
|
|
12
|
+
# class MyTask < Taski::Task
|
|
13
|
+
# exports :result
|
|
14
|
+
#
|
|
15
|
+
# def run
|
|
16
|
+
# @result = "completed"
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
8
19
|
class Task
|
|
9
20
|
class << self
|
|
21
|
+
##
|
|
22
|
+
# Callback invoked when a subclass is created.
|
|
23
|
+
# Automatically creates a task-specific Error class for each subclass.
|
|
24
|
+
# @param subclass [Class] The newly created subclass.
|
|
25
|
+
def inherited(subclass)
|
|
26
|
+
super
|
|
27
|
+
# Create TaskClass::Error that inherits from Taski::TaskError
|
|
28
|
+
error_class = Class.new(Taski::TaskError)
|
|
29
|
+
subclass.const_set(:Error, error_class)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Declares exported methods that will be accessible after task execution.
|
|
34
|
+
# Creates instance reader and class accessor methods for each export.
|
|
35
|
+
# @param export_methods [Array<Symbol>] The method names to export.
|
|
10
36
|
def exports(*export_methods)
|
|
11
37
|
@exported_methods = export_methods
|
|
12
38
|
|
|
@@ -16,75 +42,128 @@ module Taski
|
|
|
16
42
|
end
|
|
17
43
|
end
|
|
18
44
|
|
|
45
|
+
##
|
|
46
|
+
# Returns the list of exported method names.
|
|
47
|
+
# @return [Array<Symbol>] The exported method names.
|
|
19
48
|
def exported_methods
|
|
20
49
|
@exported_methods ||= []
|
|
21
50
|
end
|
|
22
51
|
|
|
23
|
-
|
|
52
|
+
##
|
|
53
|
+
# Creates a fresh TaskWrapper instance for re-execution support.
|
|
24
54
|
# Use class methods (e.g., MyTask.result) for cached single execution.
|
|
55
|
+
# @return [Execution::TaskWrapper] A new wrapper for this task.
|
|
25
56
|
def new
|
|
26
|
-
|
|
27
|
-
task_instance = allocate
|
|
28
|
-
task_instance.send(:initialize)
|
|
29
|
-
wrapper = Execution::TaskWrapper.new(
|
|
30
|
-
task_instance,
|
|
31
|
-
registry: fresh_registry
|
|
32
|
-
)
|
|
33
|
-
# Pre-register to prevent Executor from creating a duplicate wrapper
|
|
34
|
-
fresh_registry.register(self, wrapper)
|
|
35
|
-
wrapper
|
|
57
|
+
fresh_wrapper
|
|
36
58
|
end
|
|
37
59
|
|
|
60
|
+
##
|
|
61
|
+
# Returns cached static dependencies for this task class.
|
|
62
|
+
# Dependencies are analyzed from the run method body using static analysis.
|
|
63
|
+
# @return [Set<Class>] The set of task classes this task depends on.
|
|
38
64
|
def cached_dependencies
|
|
39
65
|
@dependencies_cache ||= StaticAnalysis::Analyzer.analyze(self)
|
|
40
66
|
end
|
|
41
67
|
|
|
68
|
+
##
|
|
69
|
+
# Clears the cached dependency analysis.
|
|
70
|
+
# Useful when task code has changed and dependencies need to be re-analyzed.
|
|
42
71
|
def clear_dependency_cache
|
|
43
72
|
@dependencies_cache = nil
|
|
44
73
|
end
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
##
|
|
76
|
+
# Executes the task and all its dependencies.
|
|
77
|
+
# Creates a fresh registry each time for independent execution.
|
|
78
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
79
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
80
|
+
# Must be a positive integer or nil.
|
|
81
|
+
# Use workers: 1 for sequential execution (useful for debugging).
|
|
82
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
83
|
+
# @return [Object] The result of task execution.
|
|
84
|
+
def run(args: {}, workers: nil)
|
|
85
|
+
with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run }
|
|
50
86
|
end
|
|
51
87
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
88
|
+
##
|
|
89
|
+
# Executes the clean phase for the task and all its dependencies.
|
|
90
|
+
# Clean is executed in reverse dependency order.
|
|
91
|
+
# Creates a fresh registry each time for independent execution.
|
|
92
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
93
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
94
|
+
# Must be a positive integer or nil.
|
|
95
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
96
|
+
def clean(args: {}, workers: nil)
|
|
97
|
+
with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.clean }
|
|
56
98
|
end
|
|
57
99
|
|
|
58
|
-
|
|
59
|
-
|
|
100
|
+
##
|
|
101
|
+
# Execute run followed by clean in a single operation.
|
|
102
|
+
# If run fails, clean is still executed for resource release.
|
|
103
|
+
# Creates a fresh registry for both operations to share.
|
|
104
|
+
#
|
|
105
|
+
# @param args [Hash] User-defined arguments accessible via Taski.args.
|
|
106
|
+
# @param workers [Integer, nil] Number of worker threads for parallel execution.
|
|
107
|
+
# Must be a positive integer or nil.
|
|
108
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
109
|
+
# @return [Object] The result of task execution
|
|
110
|
+
def run_and_clean(args: {}, workers: nil)
|
|
111
|
+
with_execution_setup(args: args, workers: workers) { |wrapper| wrapper.run_and_clean }
|
|
60
112
|
end
|
|
61
113
|
|
|
114
|
+
##
|
|
115
|
+
# Resets the task state and progress display.
|
|
116
|
+
# Useful for testing or re-running tasks from scratch.
|
|
62
117
|
def reset!
|
|
63
|
-
|
|
64
|
-
Taski.
|
|
65
|
-
Taski.reset_context!
|
|
118
|
+
Taski.reset_args!
|
|
119
|
+
Taski.reset_progress_display!
|
|
66
120
|
@circular_dependency_checked = false
|
|
67
121
|
end
|
|
68
122
|
|
|
123
|
+
##
|
|
124
|
+
# Renders a static tree representation of the task dependencies.
|
|
125
|
+
# @return [String] The rendered tree string.
|
|
69
126
|
def tree
|
|
70
127
|
Execution::TreeProgressDisplay.render_static_tree(self)
|
|
71
128
|
end
|
|
72
129
|
|
|
73
130
|
private
|
|
74
131
|
|
|
75
|
-
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
132
|
+
##
|
|
133
|
+
# Sets up execution environment and yields a fresh wrapper.
|
|
134
|
+
# Handles workers validation, args lifecycle, and dependency validation.
|
|
135
|
+
# @param args [Hash] User-defined arguments
|
|
136
|
+
# @param workers [Integer, nil] Number of worker threads
|
|
137
|
+
# @yield [wrapper] Block receiving the fresh wrapper to execute
|
|
138
|
+
# @return [Object] The result of the block
|
|
139
|
+
def with_execution_setup(args:, workers:)
|
|
140
|
+
validate_workers!(workers)
|
|
141
|
+
Taski.with_args(options: args.merge(_workers: workers), root_task: self) do
|
|
142
|
+
validate_no_circular_dependencies!
|
|
143
|
+
yield fresh_wrapper
|
|
85
144
|
end
|
|
86
145
|
end
|
|
87
146
|
|
|
147
|
+
##
|
|
148
|
+
# Creates a fresh TaskWrapper with its own registry.
|
|
149
|
+
# Used for class method execution (Task.run) where each call is independent.
|
|
150
|
+
# @return [Execution::TaskWrapper] A new wrapper with fresh registry.
|
|
151
|
+
def fresh_wrapper
|
|
152
|
+
fresh_registry = Execution::Registry.new
|
|
153
|
+
task_instance = allocate
|
|
154
|
+
task_instance.__send__(:initialize)
|
|
155
|
+
wrapper = Execution::TaskWrapper.new(
|
|
156
|
+
task_instance,
|
|
157
|
+
registry: fresh_registry,
|
|
158
|
+
execution_context: Execution::ExecutionContext.current
|
|
159
|
+
)
|
|
160
|
+
fresh_registry.register(self, wrapper)
|
|
161
|
+
wrapper
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
##
|
|
165
|
+
# Defines an instance reader method for an exported value.
|
|
166
|
+
# @param method [Symbol] The method name to define.
|
|
88
167
|
def define_instance_reader(method)
|
|
89
168
|
undef_method(method) if method_defined?(method)
|
|
90
169
|
|
|
@@ -94,16 +173,43 @@ module Taski
|
|
|
94
173
|
end
|
|
95
174
|
end
|
|
96
175
|
|
|
176
|
+
##
|
|
177
|
+
# Defines a class accessor method for an exported value.
|
|
178
|
+
# When called inside an execution, returns cached value from registry.
|
|
179
|
+
# When called outside execution, creates fresh execution.
|
|
180
|
+
# @param method [Symbol] The method name to define.
|
|
97
181
|
def define_class_accessor(method)
|
|
98
182
|
singleton_class.undef_method(method) if singleton_class.method_defined?(method)
|
|
99
183
|
|
|
100
184
|
define_singleton_method(method) do
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
185
|
+
# Check if running inside an execution with a registry
|
|
186
|
+
registry = Taski.current_registry
|
|
187
|
+
if registry
|
|
188
|
+
# Inside execution - get or create wrapper in current registry
|
|
189
|
+
# This handles both pre-registered dependencies and dynamic ones (like Section impl)
|
|
190
|
+
wrapper = registry.get_or_create(self) do
|
|
191
|
+
task_instance = allocate
|
|
192
|
+
task_instance.__send__(:initialize)
|
|
193
|
+
Execution::TaskWrapper.new(
|
|
194
|
+
task_instance,
|
|
195
|
+
registry: registry,
|
|
196
|
+
execution_context: Execution::ExecutionContext.current
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
wrapper.get_exported_value(method)
|
|
200
|
+
else
|
|
201
|
+
# Outside execution - fresh execution (top-level call)
|
|
202
|
+
Taski.with_args(options: {}, root_task: self) do
|
|
203
|
+
validate_no_circular_dependencies!
|
|
204
|
+
fresh_wrapper.get_exported_value(method)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
104
207
|
end
|
|
105
208
|
end
|
|
106
209
|
|
|
210
|
+
##
|
|
211
|
+
# Validates that no circular dependencies exist in the task graph.
|
|
212
|
+
# @raise [Taski::CircularDependencyError] If circular dependencies are detected.
|
|
107
213
|
def validate_no_circular_dependencies!
|
|
108
214
|
return if @circular_dependency_checked
|
|
109
215
|
|
|
@@ -116,15 +222,90 @@ module Taski
|
|
|
116
222
|
|
|
117
223
|
@circular_dependency_checked = true
|
|
118
224
|
end
|
|
225
|
+
|
|
226
|
+
##
|
|
227
|
+
# Validates the workers parameter.
|
|
228
|
+
# @param workers [Object] The workers parameter to validate.
|
|
229
|
+
# @raise [ArgumentError] If workers is not a positive integer or nil.
|
|
230
|
+
def validate_workers!(workers)
|
|
231
|
+
return if workers.nil?
|
|
232
|
+
|
|
233
|
+
unless workers.is_a?(Integer) && workers >= 1
|
|
234
|
+
raise ArgumentError, "workers must be a positive integer or nil, got: #{workers.inspect}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
119
237
|
end
|
|
120
238
|
|
|
239
|
+
##
|
|
240
|
+
# Executes the task's main logic.
|
|
241
|
+
# Subclasses must override this method to implement task behavior.
|
|
242
|
+
# @raise [NotImplementedError] If not overridden in a subclass.
|
|
121
243
|
def run
|
|
122
244
|
raise NotImplementedError, "Subclasses must implement the run method"
|
|
123
245
|
end
|
|
124
246
|
|
|
247
|
+
##
|
|
248
|
+
# Cleans up resources after task execution.
|
|
249
|
+
# Override in subclasses to implement cleanup logic.
|
|
125
250
|
def clean
|
|
126
251
|
end
|
|
127
252
|
|
|
253
|
+
# Override system() to capture subprocess output through the pipe-based architecture.
|
|
254
|
+
# Uses Kernel.system with :out option to redirect output to the task's pipe.
|
|
255
|
+
# If user provides :out or :err options, they are respected (no automatic redirection).
|
|
256
|
+
# @param args [Array] Command arguments (shell mode if single string, exec mode if array)
|
|
257
|
+
# @param opts [Hash] Options passed to Kernel.system
|
|
258
|
+
# @return [Boolean, nil] true if command succeeded, false if failed, nil if command not found
|
|
259
|
+
def system(*args, **opts)
|
|
260
|
+
write_io = $stdout.respond_to?(:current_write_io) ? $stdout.current_write_io : nil
|
|
261
|
+
|
|
262
|
+
if write_io && !opts.key?(:out)
|
|
263
|
+
# Redirect subprocess output to the task's pipe (stderr merged into stdout)
|
|
264
|
+
Kernel.system(*args, out: write_io, err: [:child, :out], **opts)
|
|
265
|
+
else
|
|
266
|
+
# No capture active or user provided custom :out, use normal system
|
|
267
|
+
Kernel.system(*args, **opts)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Groups related output within a task for organized progress display.
|
|
272
|
+
# The group name is shown in the progress tree as a child of the task.
|
|
273
|
+
# Groups cannot be nested.
|
|
274
|
+
#
|
|
275
|
+
# @param name [String] The group name to display
|
|
276
|
+
# @yield The block to execute within the group
|
|
277
|
+
# @return [Object] The result of the block
|
|
278
|
+
# @raise Re-raises any exception from the block after marking group as failed
|
|
279
|
+
#
|
|
280
|
+
# @example
|
|
281
|
+
# def run
|
|
282
|
+
# group("Preparing") do
|
|
283
|
+
# puts "Checking dependencies..."
|
|
284
|
+
# puts "Validating config..."
|
|
285
|
+
# end
|
|
286
|
+
# group("Deploying") do
|
|
287
|
+
# puts "Uploading files..."
|
|
288
|
+
# end
|
|
289
|
+
# end
|
|
290
|
+
def group(name)
|
|
291
|
+
context = Execution::ExecutionContext.current
|
|
292
|
+
context&.notify_group_started(self.class, name)
|
|
293
|
+
start_time = Time.now
|
|
294
|
+
|
|
295
|
+
begin
|
|
296
|
+
result = yield
|
|
297
|
+
duration_ms = ((Time.now - start_time) * 1000).round(0)
|
|
298
|
+
context&.notify_group_completed(self.class, name, duration: duration_ms)
|
|
299
|
+
result
|
|
300
|
+
rescue => e
|
|
301
|
+
duration_ms = ((Time.now - start_time) * 1000).round(0)
|
|
302
|
+
context&.notify_group_completed(self.class, name, duration: duration_ms, error: e)
|
|
303
|
+
raise
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
##
|
|
308
|
+
# Resets the instance's exported values to nil.
|
|
128
309
|
def reset!
|
|
129
310
|
self.class.exported_methods.each do |method|
|
|
130
311
|
instance_variable_set("@#{method}", nil)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Taski
|
|
4
|
+
module TestHelper
|
|
5
|
+
# Raised when attempting to mock a class that is not a Taski::Task or Taski::Section subclass.
|
|
6
|
+
class InvalidTaskError < ArgumentError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Raised when attempting to mock a method that is not an exported method of the task.
|
|
10
|
+
class InvalidMethodError < ArgumentError
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
module Taski
|
|
6
|
+
module TestHelper
|
|
7
|
+
# Minitest integration module.
|
|
8
|
+
# Include this in your test class for automatic mock cleanup.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class MyTaskTest < Minitest::Test
|
|
12
|
+
# include Taski::TestHelper::Minitest
|
|
13
|
+
#
|
|
14
|
+
# def test_something
|
|
15
|
+
# mock_task(FetchData, result: "mocked")
|
|
16
|
+
# # ... test code ...
|
|
17
|
+
# end
|
|
18
|
+
# # Mocks are automatically cleaned up after each test
|
|
19
|
+
# end
|
|
20
|
+
module Minitest
|
|
21
|
+
def self.included(base)
|
|
22
|
+
base.include(Taski::TestHelper)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Reset mocks before each test to ensure clean state.
|
|
26
|
+
def setup
|
|
27
|
+
super
|
|
28
|
+
Taski::TestHelper.reset_mocks!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Reset mocks after each test to prevent pollution.
|
|
32
|
+
def teardown
|
|
33
|
+
Taski::TestHelper.reset_mocks!
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Taski
|
|
4
|
+
module TestHelper
|
|
5
|
+
# Global registry that stores mock definitions for tests.
|
|
6
|
+
# Uses a Mutex for thread-safety when accessed from worker threads.
|
|
7
|
+
# Mocks should be reset in test setup/teardown to ensure test isolation.
|
|
8
|
+
module MockRegistry
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@mocks = {}
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Registers a mock for a task class.
|
|
14
|
+
# If a mock already exists for this class, it is replaced.
|
|
15
|
+
# @param task_class [Class] The task class to mock
|
|
16
|
+
# @param mock_wrapper [MockWrapper] The mock wrapper instance
|
|
17
|
+
def register(task_class, mock_wrapper)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
@mocks[task_class] = mock_wrapper
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Retrieves the mock wrapper for a task class, if one exists.
|
|
24
|
+
# @param task_class [Class] The task class to look up
|
|
25
|
+
# @return [MockWrapper, nil] The mock wrapper or nil if not mocked
|
|
26
|
+
def mock_for(task_class)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@mocks[task_class]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Checks if any mocks are registered.
|
|
33
|
+
# Used for optimization to skip mock lookup in hot paths.
|
|
34
|
+
# @return [Boolean] true if mocks exist
|
|
35
|
+
def mocks_active?
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
!@mocks.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Clears all registered mocks.
|
|
42
|
+
# Should be called in test setup/teardown.
|
|
43
|
+
def reset!
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@mocks = {}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Taski
|
|
4
|
+
module TestHelper
|
|
5
|
+
# Wraps a mocked task and returns pre-configured values without executing the task.
|
|
6
|
+
# Tracks which methods were accessed for verification in tests.
|
|
7
|
+
class MockWrapper
|
|
8
|
+
attr_reader :task_class, :mock_values
|
|
9
|
+
|
|
10
|
+
# @param task_class [Class] The task class being mocked
|
|
11
|
+
# @param mock_values [Hash{Symbol => Object}] Method names mapped to their return values
|
|
12
|
+
def initialize(task_class, mock_values)
|
|
13
|
+
@task_class = task_class
|
|
14
|
+
@mock_values = mock_values
|
|
15
|
+
@access_counts = Hash.new(0)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the mocked value for a method and records the access.
|
|
19
|
+
# @param method_name [Symbol] The exported method name
|
|
20
|
+
# @return [Object] The pre-configured mock value
|
|
21
|
+
# @raise [KeyError] If method_name was not configured in the mock
|
|
22
|
+
def get_exported_value(method_name)
|
|
23
|
+
unless @mock_values.key?(method_name)
|
|
24
|
+
raise KeyError, "No mock value for method :#{method_name} on #{@task_class}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@access_counts[method_name] += 1
|
|
28
|
+
@mock_values[method_name]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Checks if a method was accessed at least once.
|
|
32
|
+
# @param method_name [Symbol] The method name to check
|
|
33
|
+
# @return [Boolean] true if accessed at least once
|
|
34
|
+
def accessed?(method_name)
|
|
35
|
+
@access_counts[method_name] > 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the number of times a method was accessed.
|
|
39
|
+
# @param method_name [Symbol] The method name to check
|
|
40
|
+
# @return [Integer] Number of accesses (0 if never accessed)
|
|
41
|
+
def access_count(method_name)
|
|
42
|
+
@access_counts[method_name]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
module Taski
|
|
6
|
+
module TestHelper
|
|
7
|
+
# RSpec integration module.
|
|
8
|
+
# Include this in your RSpec examples for automatic mock cleanup.
|
|
9
|
+
#
|
|
10
|
+
# @example In spec_helper.rb
|
|
11
|
+
# require 'taski/test_helper/rspec'
|
|
12
|
+
#
|
|
13
|
+
# RSpec.configure do |config|
|
|
14
|
+
# config.include Taski::TestHelper::RSpec
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example In individual specs
|
|
18
|
+
# RSpec.describe MyTask do
|
|
19
|
+
# include Taski::TestHelper::RSpec
|
|
20
|
+
#
|
|
21
|
+
# it "processes data" do
|
|
22
|
+
# mock_task(FetchData, result: "mocked")
|
|
23
|
+
# # ... test code ...
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
module RSpec
|
|
27
|
+
def self.included(base)
|
|
28
|
+
base.include(Taski::TestHelper)
|
|
29
|
+
|
|
30
|
+
# Add before/after hooks when included in RSpec
|
|
31
|
+
if base.respond_to?(:before) && base.respond_to?(:after)
|
|
32
|
+
base.before(:each) { Taski::TestHelper.reset_mocks! }
|
|
33
|
+
base.after(:each) { Taski::TestHelper.reset_mocks! }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|