axn 0.1.0.pre.alpha.2.5.3.1 → 0.1.0.pre.alpha.2.6

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.
@@ -1,163 +1,143 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "event_handlers"
3
+ # TODO: maybe namespace those under core?
4
+ require "action/core/event_handlers"
4
5
 
5
6
  module Action
6
- module HandleExceptions
7
- def self.included(base)
8
- base.class_eval do
9
- class_attribute :_success_msg, :_error_msg
10
- class_attribute :_custom_error_interceptors, default: []
11
- class_attribute :_error_handlers, default: []
12
- class_attribute :_exception_handlers, default: []
13
- class_attribute :_failure_handlers, default: []
14
-
15
- include InstanceMethods
16
- extend ClassMethods
17
-
18
- def run
19
- run!
20
- rescue StandardError => e
21
- # on_error handlers run for both unhandled exceptions and fail!
22
- self.class._error_handlers.each do |handler|
23
- handler.execute_if_matches(exception: e, action: self)
24
- end
25
-
26
- # on_failure handlers run ONLY for fail!
27
- if e.is_a?(Action::Failure)
28
- @context.instance_variable_set("@error_from_user", e.message) if e.message.present?
29
-
30
- self.class._failure_handlers.each do |handler|
31
- handler.execute_if_matches(exception: e, action: self)
7
+ module Core
8
+ module HandleExceptions
9
+ def self.included(base)
10
+ base.class_eval do
11
+ class_attribute :_success_msg, :_error_msg
12
+ class_attribute :_custom_error_interceptors, default: []
13
+ class_attribute :_error_handlers, default: []
14
+ class_attribute :_exception_handlers, default: []
15
+ class_attribute :_failure_handlers, default: []
16
+ class_attribute :_success_handlers, default: []
17
+
18
+ include InstanceMethods
19
+ extend ClassMethods
20
+
21
+ def trigger_on_exception(exception)
22
+ interceptor = self.class._error_interceptor_for(exception:, action: self)
23
+ return if interceptor&.should_report_error == false
24
+
25
+ # Call any handlers registered on *this specific action* class
26
+ self.class._exception_handlers.each do |handler|
27
+ handler.execute_if_matches(exception:, action: self)
32
28
  end
33
- else
34
- # on_exception handlers run for ONLY for unhandled exceptions. AND NOTE: may be skipped if the exception is rescued via `rescues`.
35
- trigger_on_exception(e)
36
29
 
37
- @context.exception = e
30
+ # Call any global handlers
31
+ Action.config.on_exception(exception,
32
+ action: self,
33
+ context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
34
+ rescue StandardError => e
35
+ # No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
36
+ # we don't want exception *handling* failures to cascade and overwrite the original exception.
37
+ Axn::Util.piping_error("executing on_exception hooks", action: self, exception: e)
38
38
  end
39
39
 
40
- @context.instance_variable_set("@failure", true)
40
+ def trigger_on_success
41
+ # Call success handlers in child-first order (like after hooks)
42
+ self.class._success_handlers.each do |handler|
43
+ instance_exec(&handler)
44
+ rescue StandardError => e
45
+ # Log the error but continue with other handlers
46
+ Axn::Util.piping_error("executing on_success hook", action: self, exception: e)
47
+ end
48
+ end
41
49
  end
50
+ end
42
51
 
43
- def trigger_on_exception(exception)
44
- interceptor = self.class._error_interceptor_for(exception:, action: self)
45
- return if interceptor&.should_report_error == false
46
-
47
- # Call any handlers registered on *this specific action* class
48
- self.class._exception_handlers.each do |handler|
49
- handler.execute_if_matches(exception:, action: self)
50
- end
52
+ module ClassMethods
53
+ def messages(success: nil, error: nil)
54
+ self._success_msg = success if success.present?
55
+ self._error_msg = error if error.present?
51
56
 
52
- # Call any global handlers
53
- Action.config.on_exception(exception,
54
- action: self,
55
- context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
56
- rescue StandardError => e
57
- # No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
58
- # we don't want exception *handling* failures to cascade and overwrite the original exception.
59
- Axn::Util.piping_error("executing on_exception hooks", action: self, exception: e)
57
+ true
60
58
  end
61
59
 
62
- class << base
63
- def call!(context = {})
64
- result = call(context)
65
- return result if result.ok?
66
-
67
- raise result.exception || Action::Failure.new(result.error)
68
- end
60
+ def error_from(matcher = nil, message = nil, **match_and_messages)
61
+ _register_error_interceptor(matcher, message, should_report_error: true, **match_and_messages)
69
62
  end
70
- end
71
- end
72
63
 
73
- module ClassMethods
74
- def messages(success: nil, error: nil)
75
- self._success_msg = success if success.present?
76
- self._error_msg = error if error.present?
64
+ def rescues(matcher = nil, message = nil, **match_and_messages)
65
+ _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
66
+ end
77
67
 
78
- true
79
- end
68
+ # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
69
+ def on_exception(matcher = -> { true }, &handler)
70
+ raise ArgumentError, "on_exception must be called with a block" unless block_given?
80
71
 
81
- def error_from(matcher = nil, message = nil, **match_and_messages)
82
- _register_error_interceptor(matcher, message, should_report_error: true, **match_and_messages)
83
- end
72
+ self._exception_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
73
+ end
84
74
 
85
- def rescues(matcher = nil, message = nil, **match_and_messages)
86
- _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
87
- end
75
+ # ONLY raised on fail! (i.e. NOT unhandled exceptions).
76
+ def on_failure(matcher = -> { true }, &handler)
77
+ raise ArgumentError, "on_failure must be called with a block" unless block_given?
88
78
 
89
- # ONLY raised exceptions (i.e. NOT fail!). Skipped if exception is rescued via .rescues.
90
- def on_exception(matcher = -> { true }, &handler)
91
- raise ArgumentError, "on_exception must be called with a block" unless block_given?
79
+ self._failure_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
80
+ end
92
81
 
93
- self._exception_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
94
- end
82
+ # Handles both fail! and unhandled exceptions... but is NOT affected by .rescues
83
+ def on_error(matcher = -> { true }, &handler)
84
+ raise ArgumentError, "on_error must be called with a block" unless block_given?
95
85
 
96
- # ONLY raised on fail! (i.e. NOT unhandled exceptions).
97
- def on_failure(matcher = -> { true }, &handler)
98
- raise ArgumentError, "on_failure must be called with a block" unless block_given?
86
+ self._error_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
87
+ end
99
88
 
100
- self._failure_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
101
- end
89
+ # Executes when the action completes successfully (after all after hooks complete successfully)
90
+ # Runs in child-first order (child handlers before parent handlers)
91
+ def on_success(&handler)
92
+ raise ArgumentError, "on_success must be called with a block" unless block_given?
102
93
 
103
- # Handles both fail! and unhandled exceptions... but is NOT affected by .rescues
104
- def on_error(matcher = -> { true }, &handler)
105
- raise ArgumentError, "on_error must be called with a block" unless block_given?
94
+ # Prepend like after hooks - child handlers run before parent handlers
95
+ self._success_handlers = [handler] + _success_handlers
96
+ end
106
97
 
107
- self._error_handlers += [Action::EventHandlers::ConditionalHandler.new(matcher:, handler:)]
108
- end
98
+ def default_error = new.internal_context.default_error
109
99
 
110
- # Syntactic sugar for "after { try" (after, but if it fails do NOT fail the action)
111
- def on_success(&block)
112
- raise ArgumentError, "on_success must be called with a block" unless block_given?
100
+ # Private helpers
113
101
 
114
- after do
115
- try { instance_exec(&block) }
102
+ def _error_interceptor_for(exception:, action:)
103
+ Array(_custom_error_interceptors).detect do |int|
104
+ int.matches?(exception:, action:)
105
+ end
116
106
  end
117
- end
118
107
 
119
- def default_error = new.internal_context.default_error
108
+ def _register_error_interceptor(matcher, message, should_report_error:, **match_and_messages)
109
+ method_name = should_report_error ? "error_from" : "rescues"
110
+ raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
120
111
 
121
- # Private helpers
112
+ interceptors = { matcher => message }.compact.merge(match_and_messages).map do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
113
+ Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
114
+ end
122
115
 
123
- def _error_interceptor_for(exception:, action:)
124
- Array(_custom_error_interceptors).detect do |int|
125
- int.matches?(exception:, action:)
116
+ self._custom_error_interceptors += interceptors
126
117
  end
127
118
  end
128
119
 
129
- def _register_error_interceptor(matcher, message, should_report_error:, **match_and_messages)
130
- method_name = should_report_error ? "error_from" : "rescues"
131
- raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
132
-
133
- interceptors = { matcher => message }.compact.merge(match_and_messages).map do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
134
- Action::EventHandlers::CustomErrorInterceptor.new(matcher:, message:, should_report_error:)
135
- end
120
+ module InstanceMethods
121
+ private
136
122
 
137
- self._custom_error_interceptors += interceptors
138
- end
139
- end
140
-
141
- module InstanceMethods
142
- private
123
+ def fail!(message = nil)
124
+ @context.instance_variable_set("@failure", true)
125
+ @context.error_from_user = message if message.present?
143
126
 
144
- def fail!(message = nil)
145
- @context.instance_variable_set("@failure", true)
146
- @context.error_from_user = message if message.present?
127
+ raise Action::Failure, message
128
+ end
147
129
 
148
- raise Action::Failure, message
149
- end
130
+ def try
131
+ yield
132
+ rescue Action::Failure => e
133
+ # NOTE: re-raising so we can still fail! from inside the block
134
+ raise e
135
+ rescue StandardError => e
136
+ trigger_on_exception(e)
137
+ end
150
138
 
151
- def try
152
- yield
153
- rescue Action::Failure => e
154
- # NOTE: re-raising so we can still fail! from inside the block
155
- raise e
156
- rescue StandardError => e
157
- trigger_on_exception(e)
139
+ delegate :default_error, to: :internal_context
158
140
  end
159
-
160
- delegate :default_error, to: :internal_context
161
141
  end
162
142
  end
163
143
  end
@@ -1,55 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Action
4
- module HoistErrors
5
- def self.included(base)
6
- base.class_eval do
7
- include InstanceMethods
4
+ module Core
5
+ module HoistErrors
6
+ def self.included(base)
7
+ base.class_eval do
8
+ include InstanceMethods
9
+ end
8
10
  end
9
- end
10
11
 
11
- module InstanceMethods
12
- private
12
+ module InstanceMethods
13
+ private
13
14
 
14
- MinimalFailedResult = Data.define(:error, :exception) do
15
- def ok? = false
16
- end
17
-
18
- # This method is used to ensure that the result of a block is successful before proceeding.
19
- #
20
- # Assumes success unless the block raises an exception or returns a failed result.
21
- # (i.e. if you wrap logic that is NOT an action call, it'll be successful unless it raises an exception)
22
- def hoist_errors(prefix: nil)
23
- raise ArgumentError, "#hoist_errors must be given a block to execute" unless block_given?
24
-
25
- result = begin
26
- yield
27
- rescue StandardError => e
28
- log "hoist_errors block transforming a #{e.class.name} exception: #{e.message}"
29
- MinimalFailedResult.new(error: nil, exception: e)
15
+ MinimalFailedResult = Data.define(:error, :exception) do
16
+ def ok? = false
30
17
  end
31
18
 
32
- # This ensures the last line of hoist_errors was an Action call
33
- #
34
- # CAUTION: if there are multiple calls per block, only the last one will be checked!
19
+ # This method is used to ensure that the result of a block is successful before proceeding.
35
20
  #
36
- unless result.respond_to?(:ok?)
37
- raise ArgumentError,
38
- "#hoist_errors is expected to wrap an Action call, but it returned a #{result.class.name} instead"
21
+ # Assumes success unless the block raises an exception or returns a failed result.
22
+ # (i.e. if you wrap logic that is NOT an action call, it'll be successful unless it raises an exception)
23
+ def hoist_errors(prefix: nil)
24
+ raise ArgumentError, "#hoist_errors must be given a block to execute" unless block_given?
25
+
26
+ result = begin
27
+ yield
28
+ rescue StandardError => e
29
+ log "hoist_errors block transforming a #{e.class.name} exception: #{e.message}"
30
+ MinimalFailedResult.new(error: nil, exception: e)
31
+ end
32
+
33
+ # This ensures the last line of hoist_errors was an Action call
34
+ #
35
+ # CAUTION: if there are multiple calls per block, only the last one will be checked!
36
+ #
37
+ unless result.respond_to?(:ok?)
38
+ raise ArgumentError,
39
+ "#hoist_errors is expected to wrap an Action call, but it returned a #{result.class.name} instead"
40
+ end
41
+
42
+ return result if result.ok?
43
+
44
+ _handle_hoisted_errors(result, prefix:)
39
45
  end
40
46
 
41
- return result if result.ok?
47
+ # Separate method to allow overriding in subclasses
48
+ def _handle_hoisted_errors(result, prefix: nil)
49
+ @context.exception = result.exception if result.exception.present?
50
+ @context.error_prefix = prefix if prefix.present?
42
51
 
43
- _handle_hoisted_errors(result, prefix:)
44
- end
45
-
46
- # Separate method to allow overriding in subclasses
47
- def _handle_hoisted_errors(result, prefix: nil)
48
- @context.exception = result.exception if result.exception.present?
49
- @context.error_prefix = prefix if prefix.present?
50
-
51
- error = result.exception.is_a?(Action::Failure) ? result.exception.message : result.error
52
- fail! error
52
+ error = result.exception.is_a?(Action::Failure) ? result.exception.message : result.error
53
+ fail! error
54
+ end
53
55
  end
54
56
  end
55
57
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Hooks
6
+ def self.included(base)
7
+ base.class_eval do
8
+ class_attribute :around_hooks, default: []
9
+ class_attribute :before_hooks, default: []
10
+ class_attribute :after_hooks, default: []
11
+
12
+ extend ClassMethods
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ # Public: Declare hooks to run around action execution. The around
18
+ # method may be called multiple times; subsequent calls append declared
19
+ # hooks to existing around hooks.
20
+ #
21
+ # Around hooks wrap the entire action execution, including before and
22
+ # after hooks. Parent hooks wrap child hooks (parent outside, child inside).
23
+ #
24
+ # hooks - Zero or more Symbol method names representing instance methods
25
+ # to be called around action execution. Each instance method
26
+ # invocation receives an argument representing the next link in
27
+ # the around hook chain.
28
+ # block - An optional block to be executed as a hook. If given, the block
29
+ # is executed after methods corresponding to any given Symbols.
30
+ def around(*hooks, &block)
31
+ hooks << block if block
32
+ hooks.each { |hook| self.around_hooks += [hook] }
33
+ end
34
+
35
+ # Public: Declare hooks to run before action execution. The before
36
+ # method may be called multiple times; subsequent calls append declared
37
+ # hooks to existing before hooks.
38
+ #
39
+ # Before hooks run in parent-first order (general setup first, then specific).
40
+ # Parent hooks run before child hooks.
41
+ #
42
+ # hooks - Zero or more Symbol method names representing instance methods
43
+ # to be called before action execution.
44
+ # block - An optional block to be executed as a hook. If given, the block
45
+ # is executed after methods corresponding to any given Symbols.
46
+ def before(*hooks, &block)
47
+ hooks << block if block
48
+ hooks.each { |hook| self.before_hooks += [hook] }
49
+ end
50
+
51
+ # Public: Declare hooks to run after action execution. The after
52
+ # method may be called multiple times; subsequent calls prepend declared
53
+ # hooks to existing after hooks.
54
+ #
55
+ # After hooks run in child-first order (specific cleanup first, then general).
56
+ # Child hooks run before parent hooks.
57
+ #
58
+ # hooks - Zero or more Symbol method names representing instance methods
59
+ # to be called after action execution.
60
+ # block - An optional block to be executed as a hook. If given, the block
61
+ # is executed before methods corresponding to any given Symbols.
62
+ def after(*hooks, &block)
63
+ hooks << block if block
64
+ hooks.each { |hook| self.after_hooks = [hook] + after_hooks }
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def with_hooks
71
+ run_around_hooks do
72
+ run_before_hooks
73
+ yield
74
+ run_after_hooks
75
+ end
76
+ end
77
+
78
+ # Around hooks are reversed before injection to ensure parent hooks wrap
79
+ # child hooks (parent outside, child inside).
80
+ def run_around_hooks(&block)
81
+ self.class.around_hooks.reverse.inject(block) do |chain, hook|
82
+ proc { run_hook(hook, chain) }
83
+ end.call
84
+ end
85
+
86
+ # Before hooks run in the order they were added (parent first, then child).
87
+ def run_before_hooks
88
+ run_hooks(self.class.before_hooks)
89
+ end
90
+
91
+ # After hooks are reversed to ensure child hooks run before parent hooks
92
+ # (specific cleanup first, then general).
93
+ def run_after_hooks
94
+ run_hooks(self.class.after_hooks.reverse)
95
+ end
96
+
97
+ # Internal: Run a collection of hooks. The "run_hooks" method is the common
98
+ # interface by which collections of either before or after hooks are run.
99
+ #
100
+ # hooks - An Array of Symbol and Procs.
101
+ #
102
+ # Returns nothing.
103
+ def run_hooks(hooks)
104
+ hooks.each { |hook| run_hook(hook) }
105
+ end
106
+
107
+ # Internal: Run an individual hook. The "run_hook" method is the common
108
+ # interface by which an individual hook is run. If the given hook is a
109
+ # symbol, the method is invoked whether public or private. If the hook is a
110
+ # proc, the proc is evaluated in the context of the current instance.
111
+ #
112
+ # hook - A Symbol or Proc hook.
113
+ # args - Zero or more arguments to be passed as block arguments into the
114
+ # given block or as arguments into the method described by the given
115
+ # Symbol method name.
116
+ #
117
+ # Returns nothing.
118
+ def run_hook(hook, *)
119
+ hook.is_a?(Symbol) ? send(hook, *) : instance_exec(*, &hook)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -3,33 +3,35 @@
3
3
  require "active_support/core_ext/module/delegation"
4
4
 
5
5
  module Action
6
- module Logging
7
- LEVELS = %i[debug info warn error fatal].freeze
8
-
9
- def self.included(base)
10
- base.class_eval do
11
- extend ClassMethods
12
- delegate :log, *LEVELS, to: :class
6
+ module Core
7
+ module Logging
8
+ LEVELS = %i[debug info warn error fatal].freeze
9
+
10
+ def self.included(base)
11
+ base.class_eval do
12
+ extend ClassMethods
13
+ delegate :log, *LEVELS, to: :class
14
+ end
13
15
  end
14
- end
15
16
 
16
- module ClassMethods
17
- def default_log_level = Action.config.default_log_level
17
+ module ClassMethods
18
+ def default_log_level = Action.config.default_log_level
18
19
 
19
- def log(message, level: default_log_level, before: nil, after: nil)
20
- msg = [_log_prefix, message].compact_blank.join(" ")
21
- msg = [before, msg, after].compact_blank.join if before || after
20
+ def log(message, level: default_log_level, before: nil, after: nil)
21
+ msg = [_log_prefix, message].compact_blank.join(" ")
22
+ msg = [before, msg, after].compact_blank.join if before || after
22
23
 
23
- Action.config.logger.send(level, msg)
24
- end
24
+ Action.config.logger.send(level, msg)
25
+ end
25
26
 
26
- LEVELS.each do |level|
27
- define_method(level) do |message, before: nil, after: nil|
28
- log(message, level:, before:, after:)
27
+ LEVELS.each do |level|
28
+ define_method(level) do |message, before: nil, after: nil|
29
+ log(message, level:, before:, after:)
30
+ end
29
31
  end
30
- end
31
32
 
32
- def _log_prefix = "[#{name.presence || "Anonymous Class"}]"
33
+ def _log_prefix = "[#{name.presence || "Anonymous Class"}]"
34
+ end
33
35
  end
34
36
  end
35
37
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Core
5
+ module Timing
6
+ # Get the current monotonic time
7
+ def self.now
8
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
+ end
10
+
11
+ # Calculate elapsed time in milliseconds
12
+ def self.elapsed_ms(start_time)
13
+ ((now - start_time) * 1000).round(3)
14
+ end
15
+
16
+ # Calculate elapsed time in seconds
17
+ def self.elapsed_seconds(start_time)
18
+ (now - start_time).round(6)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,27 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Action
4
- module UseStrategy
5
- extend ActiveSupport::Concern
4
+ module Core
5
+ module UseStrategy
6
+ extend ActiveSupport::Concern
6
7
 
7
- class_methods do
8
- def use(strategy_name, **config, &block)
9
- strategy = Action::Strategies.all[strategy_name.to_sym]
10
- raise StrategyNotFound, "Strategy #{strategy_name} not found" if strategy.blank?
11
- raise ArgumentError, "Strategy #{strategy_name} does not support config" if config.any? && !strategy.respond_to?(:setup)
8
+ class_methods do
9
+ def use(strategy_name, **config, &block)
10
+ strategy = Action::Strategies.all[strategy_name.to_sym]
11
+ raise StrategyNotFound, "Strategy #{strategy_name} not found" if strategy.blank?
12
+ raise ArgumentError, "Strategy #{strategy_name} does not support config" if config.any? && !strategy.respond_to?(:setup)
12
13
 
13
- # Allow dynamic setup of strategy (i.e. dynamically define module before returning)
14
- if strategy.respond_to?(:setup)
15
- configured = strategy.setup(**config, &block)
16
- raise ArgumentError, "Strategy #{strategy_name} setup method must return a module" unless configured.is_a?(Module)
14
+ # Allow dynamic setup of strategy (i.e. dynamically define module before returning)
15
+ if strategy.respond_to?(:setup)
16
+ configured = strategy.setup(**config, &block)
17
+ raise ArgumentError, "Strategy #{strategy_name} setup method must return a module" unless configured.is_a?(Module)
17
18
 
18
- strategy = configured
19
- else
20
- raise ArgumentError, "Strategy #{strategy_name} does not support config (define #setup method)" if config.any?
21
- raise ArgumentError, "Strategy #{strategy_name} does not support blocks (define #setup method)" if block_given?
22
- end
19
+ strategy = configured
20
+ else
21
+ raise ArgumentError, "Strategy #{strategy_name} does not support config (define #setup method)" if config.any?
22
+ raise ArgumentError, "Strategy #{strategy_name} does not support blocks (define #setup method)" if block_given?
23
+ end
23
24
 
24
- include strategy
25
+ include strategy
26
+ end
25
27
  end
26
28
  end
27
29
  end