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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +168 -21
  4. data/docs/GUIDE.md +394 -0
  5. data/examples/README.md +65 -17
  6. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  7. data/examples/clean_demo.rb +204 -0
  8. data/examples/data_pipeline_demo.rb +1 -1
  9. data/examples/group_demo.rb +113 -0
  10. data/examples/large_tree_demo.rb +519 -0
  11. data/examples/reexecution_demo.rb +93 -80
  12. data/examples/simple_progress_demo.rb +80 -0
  13. data/examples/system_call_demo.rb +56 -0
  14. data/lib/taski/{context.rb → args.rb} +3 -3
  15. data/lib/taski/execution/base_progress_display.rb +348 -0
  16. data/lib/taski/execution/execution_context.rb +383 -0
  17. data/lib/taski/execution/executor.rb +405 -134
  18. data/lib/taski/execution/plain_progress_display.rb +76 -0
  19. data/lib/taski/execution/registry.rb +17 -1
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/simple_progress_display.rb +173 -0
  22. data/lib/taski/execution/task_output_pipe.rb +42 -0
  23. data/lib/taski/execution/task_output_router.rb +287 -0
  24. data/lib/taski/execution/task_wrapper.rb +215 -52
  25. data/lib/taski/execution/tree_progress_display.rb +349 -212
  26. data/lib/taski/execution/worker_pool.rb +104 -0
  27. data/lib/taski/section.rb +16 -3
  28. data/lib/taski/static_analysis/visitor.rb +3 -0
  29. data/lib/taski/task.rb +218 -37
  30. data/lib/taski/test_helper/errors.rb +13 -0
  31. data/lib/taski/test_helper/minitest.rb +38 -0
  32. data/lib/taski/test_helper/mock_registry.rb +51 -0
  33. data/lib/taski/test_helper/mock_wrapper.rb +46 -0
  34. data/lib/taski/test_helper/rspec.rb +38 -0
  35. data/lib/taski/test_helper.rb +214 -0
  36. data/lib/taski/version.rb +1 -1
  37. data/lib/taski.rb +211 -23
  38. data/sig/taski.rbs +207 -27
  39. metadata +25 -8
  40. data/docs/advanced-features.md +0 -625
  41. data/docs/api-guide.md +0 -509
  42. data/docs/error-handling.md +0 -684
  43. data/examples/section_progress_demo.rb +0 -78
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper/errors"
4
+ require_relative "test_helper/mock_wrapper"
5
+ require_relative "test_helper/mock_registry"
6
+
7
+ module Taski
8
+ # Test helper module for mocking Taski task dependencies in unit tests.
9
+ # Include this module in your test class to access mocking functionality.
10
+ #
11
+ # @example Minitest usage
12
+ # class BuildReportTest < Minitest::Test
13
+ # include Taski::TestHelper
14
+ #
15
+ # def test_builds_report
16
+ # mock_task(FetchData, users: [1, 2, 3])
17
+ # assert_equal 3, BuildReport.user_count
18
+ # end
19
+ # end
20
+ module TestHelper
21
+ # Module prepended to Task's singleton class to intercept define_class_accessor.
22
+ # @api private
23
+ module TaskExtension
24
+ def define_class_accessor(method)
25
+ singleton_class.undef_method(method) if singleton_class.method_defined?(method)
26
+
27
+ define_singleton_method(method) do
28
+ # Check for mock first
29
+ mock = MockRegistry.mock_for(self)
30
+ return mock.get_exported_value(method) if mock
31
+
32
+ # No mock - call original implementation via registry lookup
33
+ registry = Taski.current_registry
34
+ if registry
35
+ wrapper = registry.get_or_create(self) do
36
+ task_instance = allocate
37
+ task_instance.__send__(:initialize)
38
+ Execution::TaskWrapper.new(
39
+ task_instance,
40
+ registry: registry,
41
+ execution_context: Execution::ExecutionContext.current
42
+ )
43
+ end
44
+ wrapper.get_exported_value(method)
45
+ else
46
+ Taski.with_args(options: {}, root_task: self) do
47
+ validate_no_circular_dependencies!
48
+ fresh_wrapper.get_exported_value(method)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # Module prepended to Scheduler to skip dependencies of mocked tasks.
56
+ # @api private
57
+ module SchedulerExtension
58
+ def build_dependency_graph(root_task_class)
59
+ queue = [root_task_class]
60
+
61
+ while (task_class = queue.shift)
62
+ next if @task_states.key?(task_class)
63
+
64
+ # Mocked tasks have no dependencies (isolates indirect dependencies)
65
+ mock = MockRegistry.mock_for(task_class)
66
+ deps = mock ? Set.new : task_class.cached_dependencies
67
+ @dependencies[task_class] = deps.dup
68
+ @task_states[task_class] = Taski::Execution::Scheduler::STATE_PENDING
69
+
70
+ deps.each { |dep| queue << dep }
71
+ end
72
+ end
73
+ end
74
+
75
+ # Module prepended to Executor to skip execution of mocked tasks.
76
+ # @api private
77
+ module ExecutorExtension
78
+ def execute_task(task_class, wrapper)
79
+ return if @registry.abort_requested?
80
+
81
+ # Skip execution if task is mocked
82
+ if MockRegistry.mock_for(task_class)
83
+ wrapper.mark_completed(nil)
84
+ @completion_queue.push({task_class: task_class, wrapper: wrapper})
85
+ return
86
+ end
87
+
88
+ super
89
+ end
90
+ end
91
+
92
+ class << self
93
+ # Checks if any mocks are currently registered.
94
+ # @return [Boolean] true if mocks exist
95
+ def mocks_active?
96
+ MockRegistry.mocks_active?
97
+ end
98
+
99
+ # Retrieves the mock wrapper for a task class.
100
+ # @param task_class [Class] The task class to look up
101
+ # @return [MockWrapper, nil] The mock wrapper or nil if not mocked
102
+ def mock_for(task_class)
103
+ MockRegistry.mock_for(task_class)
104
+ end
105
+
106
+ # Clears all registered mocks.
107
+ # Called automatically by test framework integrations.
108
+ def reset_mocks!
109
+ MockRegistry.reset!
110
+ end
111
+ end
112
+
113
+ # Registers a mock for a task class with specified return values.
114
+ # @param task_class [Class] A Taski::Task or Taski::Section subclass
115
+ # @param values [Hash{Symbol => Object}] Method names mapped to return values
116
+ # @return [MockWrapper] The created mock wrapper
117
+ # @raise [InvalidTaskError] If task_class is not a Taski::Task/Section subclass
118
+ # @raise [InvalidMethodError] If any method name is not an exported method
119
+ #
120
+ # @example
121
+ # mock_task(FetchData, result: { users: [1, 2, 3] })
122
+ # mock_task(Config, timeout: 30, retries: 3)
123
+ def mock_task(task_class, **values)
124
+ validate_task_class!(task_class)
125
+ validate_exported_methods!(task_class, values.keys)
126
+
127
+ mock_wrapper = MockWrapper.new(task_class, values)
128
+ MockRegistry.register(task_class, mock_wrapper)
129
+ mock_wrapper
130
+ end
131
+
132
+ # Asserts that a mocked task's method was accessed during the test.
133
+ # @param task_class [Class] The mocked task class
134
+ # @param method_name [Symbol] The exported method name
135
+ # @return [true] If assertion passes
136
+ # @raise [ArgumentError] If task_class was not mocked
137
+ # @raise [Minitest::Assertion, RSpec::Expectations::ExpectationNotMetError]
138
+ # If method was not accessed
139
+ def assert_task_accessed(task_class, method_name)
140
+ mock = fetch_mock!(task_class)
141
+
142
+ unless mock.accessed?(method_name)
143
+ raise assertion_error("Expected #{task_class}.#{method_name} to be accessed, but it was not")
144
+ end
145
+
146
+ true
147
+ end
148
+
149
+ # Asserts that a mocked task's method was NOT accessed during the test.
150
+ # @param task_class [Class] The mocked task class
151
+ # @param method_name [Symbol] The exported method name
152
+ # @return [true] If assertion passes
153
+ # @raise [ArgumentError] If task_class was not mocked
154
+ # @raise [Minitest::Assertion, RSpec::Expectations::ExpectationNotMetError]
155
+ # If method was accessed
156
+ def refute_task_accessed(task_class, method_name)
157
+ mock = fetch_mock!(task_class)
158
+
159
+ if mock.accessed?(method_name)
160
+ count = mock.access_count(method_name)
161
+ raise assertion_error(
162
+ "Expected #{task_class}.#{method_name} not to be accessed, but it was accessed #{count} time(s)"
163
+ )
164
+ end
165
+
166
+ true
167
+ end
168
+
169
+ private
170
+
171
+ def fetch_mock!(task_class)
172
+ mock = MockRegistry.mock_for(task_class)
173
+ return mock if mock
174
+
175
+ raise ArgumentError, "Task #{task_class} was not mocked. Call mock_task first."
176
+ end
177
+
178
+ def validate_task_class!(task_class)
179
+ valid = task_class.is_a?(Class) &&
180
+ (task_class < Taski::Task || task_class == Taski::Task)
181
+ return if valid
182
+
183
+ raise InvalidTaskError,
184
+ "Cannot mock #{task_class}: not a Taski::Task or Taski::Section subclass"
185
+ end
186
+
187
+ def validate_exported_methods!(task_class, method_names)
188
+ exported = task_class.exported_methods
189
+ method_names.each do |method_name|
190
+ unless exported.include?(method_name)
191
+ raise InvalidMethodError,
192
+ "Cannot mock :#{method_name} on #{task_class}: not an exported method. Exported: #{exported.inspect}"
193
+ end
194
+ end
195
+ end
196
+
197
+ def assertion_error(message)
198
+ # Use the appropriate assertion error class based on the test framework
199
+ # Use fully qualified names to avoid namespace conflicts
200
+ if defined?(::Minitest::Assertion)
201
+ ::Minitest::Assertion.new(message)
202
+ elsif defined?(::RSpec::Expectations::ExpectationNotMetError)
203
+ ::RSpec::Expectations::ExpectationNotMetError.new(message)
204
+ else
205
+ RuntimeError.new(message)
206
+ end
207
+ end
208
+ end
209
+ end
210
+
211
+ # Prepend extensions when test helper is loaded
212
+ Taski::Task.singleton_class.prepend(Taski::TestHelper::TaskExtension)
213
+ Taski::Execution::Scheduler.prepend(Taski::TestHelper::SchedulerExtension)
214
+ Taski::Execution::Executor.prepend(Taski::TestHelper::ExecutorExtension)
data/lib/taski/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taski
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.1"
5
5
  end
data/lib/taski.rb CHANGED
@@ -5,12 +5,15 @@ require_relative "taski/static_analysis/analyzer"
5
5
  require_relative "taski/static_analysis/visitor"
6
6
  require_relative "taski/static_analysis/dependency_graph"
7
7
  require_relative "taski/execution/registry"
8
+ require_relative "taski/execution/execution_context"
8
9
  require_relative "taski/execution/task_wrapper"
10
+ require_relative "taski/execution/scheduler"
11
+ require_relative "taski/execution/worker_pool"
9
12
  require_relative "taski/execution/executor"
10
13
  require_relative "taski/execution/tree_progress_display"
11
- require_relative "taski/context"
12
- require_relative "taski/task"
13
- require_relative "taski/section"
14
+ require_relative "taski/execution/simple_progress_display"
15
+ require_relative "taski/execution/plain_progress_display"
16
+ require_relative "taski/args"
14
17
 
15
18
  module Taski
16
19
  class TaskAbortException < StandardError
@@ -28,52 +31,237 @@ module Taski
28
31
  end
29
32
  end
30
33
 
31
- @context_monitor = Monitor.new
34
+ # Represents a single task failure with its context
35
+ class TaskFailure
36
+ attr_reader :task_class, :error, :output_lines
32
37
 
33
- # Get the current execution context
34
- # @return [Context, nil] The current context or nil if no task is running
35
- def self.context
36
- @context_monitor.synchronize { @context }
38
+ # @param task_class [Class] The task class that failed
39
+ # @param error [Exception] The exception that was raised
40
+ # @param output_lines [Array<String>] Recent output lines from the failed task
41
+ def initialize(task_class:, error:, output_lines: [])
42
+ @task_class = task_class
43
+ @error = error
44
+ @output_lines = output_lines
45
+ end
37
46
  end
38
47
 
39
- # Start a new execution context (internal use only)
40
- # @api private
41
- def self.start_context(options:, root_task:)
42
- @context_monitor.synchronize do
43
- return if @context
44
- @context = Context.new(options: options, root_task: root_task)
48
+ # Mixin for exception classes to enable transparent rescue matching with AggregateError.
49
+ # When extended by an exception class, `rescue ThatError` will also match
50
+ # an AggregateError that contains ThatError.
51
+ #
52
+ # @note TaskError and all TaskClass::Error classes already extend this module.
53
+ #
54
+ # @example
55
+ # begin
56
+ # MyTask.value # raises AggregateError containing MyTask::Error
57
+ # rescue MyTask::Error => e
58
+ # puts "MyTask failed: #{e.message}"
59
+ # end
60
+ module AggregateAware
61
+ def ===(other)
62
+ return super unless other.is_a?(Taski::AggregateError)
63
+
64
+ other.includes?(self)
65
+ end
66
+ end
67
+
68
+ # Base class for task-specific error wrappers.
69
+ # Each Task subclass automatically gets a ::Error class that inherits from this.
70
+ # This allows rescuing errors by task class: rescue MyTask::Error => e
71
+ #
72
+ # @example Rescuing task-specific errors
73
+ # begin
74
+ # MyTask.value
75
+ # rescue MyTask::Error => e
76
+ # puts "MyTask failed: #{e.message}"
77
+ # end
78
+ class TaskError < StandardError
79
+ extend AggregateAware
80
+
81
+ # @return [Exception] The original error that occurred in the task
82
+ attr_reader :cause
83
+
84
+ # @return [Class] The task class where the error occurred
85
+ attr_reader :task_class
86
+
87
+ # @param cause [Exception] The original error
88
+ # @param task_class [Class] The task class where the error occurred
89
+ def initialize(cause, task_class:)
90
+ @cause = cause
91
+ @task_class = task_class
92
+ super(cause.message)
93
+ set_backtrace(cause.backtrace)
94
+ end
95
+ end
96
+
97
+ # Raised when multiple tasks fail during parallel execution
98
+ class AggregateError < StandardError
99
+ attr_reader :errors
100
+
101
+ # @param errors [Array<TaskFailure>] List of task failures
102
+ def initialize(errors)
103
+ @errors = errors
104
+ super(build_message)
105
+ end
106
+
107
+ # Returns the first error for compatibility with exception chaining
108
+ # @return [Exception, nil] The first error or nil if no errors
109
+ def cause
110
+ errors.first&.error
111
+ end
112
+
113
+ # Check if this aggregate contains an error of the given type
114
+ # @param exception_class [Class] The exception class to check for
115
+ # @return [Boolean] true if any contained error is of the given type
116
+ def includes?(exception_class)
117
+ errors.any? { |f| f.error.is_a?(exception_class) }
118
+ end
119
+
120
+ private
121
+
122
+ def build_message
123
+ task_word = (errors.size == 1) ? "task" : "tasks"
124
+ parts = ["#{errors.size} #{task_word} failed:"]
125
+
126
+ errors.each do |f|
127
+ parts << " - #{f.task_class.name}: #{f.error.message}"
128
+
129
+ # Include captured output if available
130
+ if f.output_lines && !f.output_lines.empty?
131
+ parts << " Output:"
132
+ f.output_lines.each { |line| parts << " #{line}" }
133
+ end
134
+ end
135
+
136
+ parts.join("\n")
45
137
  end
46
138
  end
47
139
 
48
- # Reset the execution context (internal use only)
140
+ @args_monitor = Monitor.new
141
+
142
+ # Get the current runtime arguments
143
+ # @return [Args, nil] The current args or nil if no task is running
144
+ def self.args
145
+ @args_monitor.synchronize { @args }
146
+ end
147
+
148
+ # Start new runtime arguments (internal use only)
49
149
  # @api private
50
- def self.reset_context!
51
- @context_monitor.synchronize { @context = nil }
150
+ # @return [Boolean] true if this call created the args, false if args already existed
151
+ def self.start_args(options:, root_task:)
152
+ @args_monitor.synchronize do
153
+ return false if @args
154
+ @args = Args.new(options: options, root_task: root_task)
155
+ true
156
+ end
52
157
  end
53
158
 
54
- def self.global_registry
55
- @global_registry ||= Execution::Registry.new
159
+ # Reset the runtime arguments (internal use only)
160
+ # @api private
161
+ def self.reset_args!
162
+ @args_monitor.synchronize { @args = nil }
56
163
  end
57
164
 
58
- def self.reset_global_registry!
59
- @global_registry = nil
165
+ # Execute a block with args lifecycle management.
166
+ # Creates args if they don't exist, and resets them only if this call created them.
167
+ # This prevents race conditions in concurrent execution.
168
+ #
169
+ # @param options [Hash] User-defined options
170
+ # @param root_task [Class] The root task class
171
+ # @yield The block to execute with args available
172
+ # @return [Object] The result of the block
173
+ def self.with_args(options:, root_task:)
174
+ created_args = start_args(options: options, root_task: root_task)
175
+ yield
176
+ ensure
177
+ reset_args! if created_args
60
178
  end
61
179
 
62
180
  # Progress display is enabled by default (tree-style).
63
181
  # Environment variables:
64
182
  # - TASKI_PROGRESS_DISABLE=1: Disable progress display entirely
65
- # - TASKI_FORCE_PROGRESS=1: Force enable even without TTY (for testing)
183
+ # - TASKI_PROGRESS_MODE=simple|tree: Set display mode (default: tree)
66
184
  def self.progress_display
67
185
  return nil if progress_disabled?
68
- @progress_display ||= Execution::TreeProgressDisplay.new
186
+ @progress_display ||= create_progress_display
69
187
  end
70
188
 
71
189
  def self.progress_disabled?
72
190
  ENV["TASKI_PROGRESS_DISABLE"] == "1"
73
191
  end
74
192
 
193
+ # Get the current progress mode (:tree or :simple)
194
+ # @return [Symbol] The current progress mode
195
+ def self.progress_mode
196
+ @progress_mode || progress_mode_from_env
197
+ end
198
+
199
+ # Set the progress mode (:tree or :simple)
200
+ # @param mode [Symbol] The mode to use (:tree or :simple)
201
+ def self.progress_mode=(mode)
202
+ @progress_mode = mode.to_sym
203
+ # Reset display so it will be recreated with new mode
204
+ @progress_display&.stop
205
+ @progress_display = nil
206
+ end
207
+
75
208
  def self.reset_progress_display!
76
209
  @progress_display&.stop
77
210
  @progress_display = nil
211
+ @progress_mode = nil
212
+ end
213
+
214
+ # @api private
215
+ def self.create_progress_display
216
+ case progress_mode
217
+ when :simple
218
+ Execution::SimpleProgressDisplay.new
219
+ when :plain
220
+ Execution::PlainProgressDisplay.new
221
+ else
222
+ Execution::TreeProgressDisplay.new
223
+ end
224
+ end
225
+
226
+ # @api private
227
+ def self.progress_mode_from_env
228
+ case ENV["TASKI_PROGRESS_MODE"]
229
+ when "simple"
230
+ :simple
231
+ when "plain"
232
+ :plain
233
+ else
234
+ :tree
235
+ end
236
+ end
237
+
238
+ # Get the worker count from the current args (set via Task.run(workers: n))
239
+ # @return [Integer, nil] The worker count or nil to use WorkerPool default
240
+ # @api private
241
+ def self.args_worker_count
242
+ args&.fetch(:_workers, nil)
243
+ end
244
+
245
+ # Get the current registry for this thread (used during dependency resolution)
246
+ # @return [Execution::Registry, nil] The current registry or nil
247
+ # @api private
248
+ def self.current_registry
249
+ Thread.current[:taski_current_registry]
250
+ end
251
+
252
+ # Set the current registry for this thread (internal use only)
253
+ # @api private
254
+ def self.set_current_registry(registry)
255
+ Thread.current[:taski_current_registry] = registry
256
+ end
257
+
258
+ # Clear the current registry for this thread (internal use only)
259
+ # @api private
260
+ def self.clear_current_registry
261
+ Thread.current[:taski_current_registry] = nil
78
262
  end
79
263
  end
264
+
265
+ # Load Task and Section after Taski module is defined (they depend on TaskError)
266
+ require_relative "taski/task"
267
+ require_relative "taski/section"