typed_operation 1.0.0.beta3 → 1.0.0.beta4
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/README.md +76 -724
- data/lib/generators/typed_operation/install/install_generator.rb +3 -0
- data/lib/generators/typed_operation_generator.rb +8 -4
- data/lib/typed_operation/action_policy_auth.rb +44 -24
- data/lib/typed_operation/base.rb +4 -1
- data/lib/typed_operation/callable_resolver.rb +30 -0
- data/lib/typed_operation/chains/chained_operation.rb +27 -0
- data/lib/typed_operation/chains/fallback_chain.rb +32 -0
- data/lib/typed_operation/chains/map_chain.rb +37 -0
- data/lib/typed_operation/chains/sequence_chain.rb +54 -0
- data/lib/typed_operation/chains/smart_chain.rb +161 -0
- data/lib/typed_operation/chains/splat_chain.rb +53 -0
- data/lib/typed_operation/configuration.rb +52 -0
- data/lib/typed_operation/context.rb +193 -0
- data/lib/typed_operation/curried.rb +14 -1
- data/lib/typed_operation/explainable.rb +14 -0
- data/lib/typed_operation/immutable_base.rb +4 -1
- data/lib/typed_operation/instrumentation/trace.rb +71 -0
- data/lib/typed_operation/instrumentation/tree_formatter.rb +141 -0
- data/lib/typed_operation/instrumentation.rb +214 -0
- data/lib/typed_operation/operations/composition.rb +41 -0
- data/lib/typed_operation/operations/executable.rb +27 -1
- data/lib/typed_operation/operations/introspection.rb +9 -1
- data/lib/typed_operation/operations/lifecycle.rb +4 -1
- data/lib/typed_operation/operations/parameters.rb +11 -5
- data/lib/typed_operation/operations/partial_application.rb +4 -0
- data/lib/typed_operation/operations/property_builder.rb +46 -22
- data/lib/typed_operation/partially_applied.rb +33 -10
- data/lib/typed_operation/pipeline/builder.rb +88 -0
- data/lib/typed_operation/pipeline/chainable_wrapper.rb +23 -0
- data/lib/typed_operation/pipeline/empty_pipeline_chain.rb +25 -0
- data/lib/typed_operation/pipeline/step_wrapper.rb +94 -0
- data/lib/typed_operation/pipeline.rb +176 -0
- data/lib/typed_operation/prepared.rb +13 -0
- data/lib/typed_operation/railtie.rb +4 -0
- data/lib/typed_operation/result/adapters/built_in.rb +28 -0
- data/lib/typed_operation/result/adapters/dry_monads.rb +36 -0
- data/lib/typed_operation/result/failure.rb +78 -0
- data/lib/typed_operation/result/mixin.rb +24 -0
- data/lib/typed_operation/result/success.rb +75 -0
- data/lib/typed_operation/result.rb +39 -0
- data/lib/typed_operation/version.rb +5 -1
- data/lib/typed_operation.rb +18 -1
- metadata +27 -3
- data/lib/typed_operation/operations/callable.rb +0 -23
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Context objects are for passing data through pipelines and chains.
|
|
6
|
+
#
|
|
7
|
+
# This particular implementation wraps a Hash but provides dot access for cleaner syntax.
|
|
8
|
+
# Its underlying store is mutable.
|
|
9
|
+
#
|
|
10
|
+
# Users can subclass or provide their own context that implements the same interface.
|
|
11
|
+
#
|
|
12
|
+
# Required interface for custom contexts:
|
|
13
|
+
# - #[](key) - read a value
|
|
14
|
+
# - #[]=(key, value) - write a value (if mutable)
|
|
15
|
+
# - #key?(key) - check if key exists
|
|
16
|
+
# - #to_h - convert to Hash (for kwargs extraction)
|
|
17
|
+
# - #merge(other) - combine with another context/hash, returns new context
|
|
18
|
+
#
|
|
19
|
+
# Dot access is not required. It's up to you if you prefer to have it in your operation usage.
|
|
20
|
+
#
|
|
21
|
+
class Context
|
|
22
|
+
# @rbs @data: Hash[Symbol, untyped]
|
|
23
|
+
|
|
24
|
+
#: (?Hash[Symbol | String, untyped] | Context | _ToH) -> void
|
|
25
|
+
def initialize(data = {})
|
|
26
|
+
@data = coerce_to_hash(data).transform_keys(&:to_sym)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#: (Symbol | String) -> untyped
|
|
30
|
+
def [](key)
|
|
31
|
+
read_value(key.to_sym)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (Symbol | String, untyped) -> untyped
|
|
35
|
+
def []=(key, value)
|
|
36
|
+
write_value(key.to_sym, value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: (Symbol | String) -> bool
|
|
40
|
+
def key?(key)
|
|
41
|
+
@data.key?(key.to_sym)
|
|
42
|
+
end
|
|
43
|
+
alias_method :has_key?, :key?
|
|
44
|
+
|
|
45
|
+
#: () -> Hash[Symbol, untyped]
|
|
46
|
+
def to_h
|
|
47
|
+
@data.dup
|
|
48
|
+
end
|
|
49
|
+
alias_method :to_hash, :to_h
|
|
50
|
+
|
|
51
|
+
#: (Hash[Symbol | String, untyped] | Context | _ToH) -> Context
|
|
52
|
+
def merge(other)
|
|
53
|
+
self.class.new(@data.merge(coerce_to_hash(other)))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: (Symbol | String) -> untyped
|
|
57
|
+
#: [T] (Symbol | String, T) -> (untyped | T)
|
|
58
|
+
#: [T] (Symbol | String) { (Symbol) -> T } -> (untyped | T)
|
|
59
|
+
def fetch(key, *args, &block)
|
|
60
|
+
@data.fetch(key.to_sym, *args, &block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: () -> Array[Symbol]
|
|
64
|
+
def keys
|
|
65
|
+
@data.keys
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: () -> Array[untyped]
|
|
69
|
+
def values
|
|
70
|
+
@data.values
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
#: () -> Enumerator[[Symbol, untyped], self]
|
|
74
|
+
#: () { ([Symbol, untyped]) -> void } -> self
|
|
75
|
+
def each(&block)
|
|
76
|
+
return enum_for(:each) unless block_given?
|
|
77
|
+
@data.each(&block)
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: () { (Symbol, untyped) -> boolish } -> Context
|
|
82
|
+
def select(&block)
|
|
83
|
+
self.class.new(@data.select(&block))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#: () { (Symbol, untyped) -> boolish } -> Context
|
|
87
|
+
def reject(&block)
|
|
88
|
+
self.class.new(@data.reject(&block))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: () { (untyped) -> untyped } -> Context
|
|
92
|
+
def transform_values(&block)
|
|
93
|
+
self.class.new(@data.transform_values(&block))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
#: (*(Symbol | String)) -> Context
|
|
97
|
+
def slice(*keys)
|
|
98
|
+
self.class.new(@data.slice(*keys.map(&:to_sym)))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (*(Symbol | String)) -> Context
|
|
102
|
+
def except(*keys)
|
|
103
|
+
self.class.new(@data.except(*keys.map(&:to_sym)))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: () -> bool
|
|
107
|
+
def empty?
|
|
108
|
+
@data.empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
#: () -> Integer
|
|
112
|
+
def size
|
|
113
|
+
@data.size
|
|
114
|
+
end
|
|
115
|
+
alias_method :length, :size
|
|
116
|
+
|
|
117
|
+
#: (untyped) -> bool
|
|
118
|
+
def ==(other)
|
|
119
|
+
case other
|
|
120
|
+
when Context
|
|
121
|
+
@data == other.to_h
|
|
122
|
+
when Hash
|
|
123
|
+
@data == other.transform_keys(&:to_sym)
|
|
124
|
+
else
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Provide dot access for reading and writing values.
|
|
130
|
+
#: (Symbol, *untyped) ?{ (*untyped) -> untyped } -> untyped
|
|
131
|
+
def method_missing(name, *args, &block)
|
|
132
|
+
name_str = name.to_s
|
|
133
|
+
|
|
134
|
+
if name_str.end_with?("=")
|
|
135
|
+
key = name_str.chomp("=").to_sym
|
|
136
|
+
write_value(key, args.first)
|
|
137
|
+
elsif name_str.end_with?("?")
|
|
138
|
+
key = name_str.chomp("?").to_sym
|
|
139
|
+
!!read_value(key)
|
|
140
|
+
elsif args.empty? && !block
|
|
141
|
+
read_value(name)
|
|
142
|
+
else
|
|
143
|
+
super
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
#: (Symbol, ?bool) -> bool
|
|
148
|
+
def respond_to_missing?(name, include_private = false)
|
|
149
|
+
name_str = name.to_s
|
|
150
|
+
name_str.end_with?("=", "?") ||
|
|
151
|
+
key?(name) ||
|
|
152
|
+
super
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
#: () -> String
|
|
156
|
+
def inspect
|
|
157
|
+
"#<#{self.class.name} #{@data.inspect}>"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
alias_method :to_s, :inspect
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
#: (Symbol) -> untyped
|
|
165
|
+
def read_value(key)
|
|
166
|
+
@data[key]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
#: (Symbol, untyped) -> untyped
|
|
170
|
+
def write_value(key, value)
|
|
171
|
+
@data[key] = value
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @rbs!
|
|
175
|
+
# interface _ToH
|
|
176
|
+
# def to_h: () -> Hash[untyped, untyped]
|
|
177
|
+
# end
|
|
178
|
+
|
|
179
|
+
#: (untyped) -> Hash[untyped, untyped]
|
|
180
|
+
def coerce_to_hash(obj)
|
|
181
|
+
case obj
|
|
182
|
+
when Hash
|
|
183
|
+
obj
|
|
184
|
+
when Context
|
|
185
|
+
obj.to_h
|
|
186
|
+
when ->(o) { o.respond_to?(:to_h) }
|
|
187
|
+
obj.to_h
|
|
188
|
+
else
|
|
189
|
+
raise ArgumentError, "Cannot coerce #{obj.class} to Hash"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -1,19 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
module TypedOperation
|
|
5
|
+
# Wraps an operation to support automatic currying, allowing parameters to be
|
|
6
|
+
# provided one at a time. Returns a new Curried instance until all required
|
|
7
|
+
# parameters are satisfied, then executes the operation.
|
|
4
8
|
class Curried
|
|
9
|
+
# @rbs @operation_class: untyped
|
|
10
|
+
# @rbs @partial_operation: PartiallyApplied | Prepared
|
|
11
|
+
|
|
12
|
+
#: (untyped, ?(PartiallyApplied | Prepared)) -> void
|
|
5
13
|
def initialize(operation_class, partial_operation = nil)
|
|
6
14
|
@operation_class = operation_class
|
|
7
15
|
@partial_operation = partial_operation || operation_class.with
|
|
8
16
|
end
|
|
9
17
|
|
|
18
|
+
#: (untyped) -> (Curried | untyped)
|
|
10
19
|
def call(arg)
|
|
11
20
|
raise ArgumentError, "A prepared operation should not be curried" if @partial_operation.prepared?
|
|
12
21
|
|
|
13
22
|
next_partially_applied = if next_parameter_positional?
|
|
14
23
|
@partial_operation.with(arg)
|
|
15
24
|
else
|
|
16
|
-
|
|
25
|
+
key = next_keyword_parameter or raise ArgumentError, "No keyword parameter available"
|
|
26
|
+
@partial_operation.with(key => arg)
|
|
17
27
|
end
|
|
18
28
|
if next_partially_applied.prepared?
|
|
19
29
|
next_partially_applied.call
|
|
@@ -22,17 +32,20 @@ module TypedOperation
|
|
|
22
32
|
end
|
|
23
33
|
end
|
|
24
34
|
|
|
35
|
+
#: () -> Proc
|
|
25
36
|
def to_proc
|
|
26
37
|
method(:call).to_proc
|
|
27
38
|
end
|
|
28
39
|
|
|
29
40
|
private
|
|
30
41
|
|
|
42
|
+
#: () -> Symbol?
|
|
31
43
|
def next_keyword_parameter
|
|
32
44
|
remaining = @operation_class.required_keyword_parameters - @partial_operation.keyword_args.keys
|
|
33
45
|
remaining.first
|
|
34
46
|
end
|
|
35
47
|
|
|
48
|
+
#: () -> bool
|
|
36
49
|
def next_parameter_positional?
|
|
37
50
|
@partial_operation.positional_args.size < @operation_class.required_positional_parameters.size
|
|
38
51
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Include this module in your operation base class to enable the `.explain` method
|
|
6
|
+
# for runtime tracing and debugging.
|
|
7
|
+
module Explainable
|
|
8
|
+
#: (Module) -> void
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(Instrumentation::Explainable)
|
|
11
|
+
base.prepend(Instrumentation::Traceable)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
module TypedOperation
|
|
5
|
+
# Immutable base class for operations, built on Literal::Data.
|
|
6
|
+
# Use this when operation state should not be modified after initialization.
|
|
4
7
|
class ImmutableBase < Literal::Data
|
|
5
8
|
extend Operations::Introspection
|
|
6
9
|
extend Operations::Parameters
|
|
7
10
|
extend Operations::PartialApplication
|
|
11
|
+
extend Operations::Composition
|
|
8
12
|
|
|
9
13
|
include Operations::Lifecycle
|
|
10
|
-
include Operations::Callable
|
|
11
14
|
include Operations::Executable
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
module Instrumentation
|
|
6
|
+
# Represents a single operation execution trace with timing and result data.
|
|
7
|
+
# Supports nested traces for operations that call other operations.
|
|
8
|
+
class Trace
|
|
9
|
+
attr_reader :operation_class #: untyped
|
|
10
|
+
attr_reader :params #: Hash[Symbol, untyped]
|
|
11
|
+
attr_reader :start_time #: Float
|
|
12
|
+
attr_reader :children #: Array[Trace]
|
|
13
|
+
attr_accessor :end_time #: Float?
|
|
14
|
+
attr_accessor :result #: untyped
|
|
15
|
+
attr_accessor :exception #: Exception?
|
|
16
|
+
attr_accessor :pass_mode #: Symbol?
|
|
17
|
+
attr_accessor :extracted_params #: Array[Symbol]?
|
|
18
|
+
attr_accessor :fallback_used #: bool
|
|
19
|
+
|
|
20
|
+
#: (operation_class: untyped, ?params: Hash[Symbol, untyped]) -> void
|
|
21
|
+
def initialize(operation_class:, params: {})
|
|
22
|
+
@operation_class = operation_class
|
|
23
|
+
@params = params
|
|
24
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
@end_time = nil
|
|
26
|
+
@result = nil
|
|
27
|
+
@exception = nil
|
|
28
|
+
@children = []
|
|
29
|
+
@pass_mode = nil
|
|
30
|
+
@extracted_params = nil
|
|
31
|
+
@fallback_used = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (?result: untyped, ?exception: Exception?) -> self
|
|
35
|
+
def finish!(result: nil, exception: nil)
|
|
36
|
+
@end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
@result = result
|
|
38
|
+
@exception = exception
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: () -> (Float | Integer | nil)
|
|
43
|
+
def duration_ms
|
|
44
|
+
return nil unless end_time
|
|
45
|
+
((end_time - start_time) * 1000).round(2)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: () -> bool
|
|
49
|
+
def success?
|
|
50
|
+
return false if exception
|
|
51
|
+
return result.success? if result.respond_to?(:success?)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: () -> bool
|
|
56
|
+
def failure?
|
|
57
|
+
!success?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (Trace) -> void
|
|
61
|
+
def add_child(trace)
|
|
62
|
+
@children << trace
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: () -> String
|
|
66
|
+
def operation_name
|
|
67
|
+
operation_class.name || operation_class.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
module Instrumentation
|
|
6
|
+
# Formats execution traces as a visual chain for console output.
|
|
7
|
+
# Shows operation flow with arrows and pass mode indicators.
|
|
8
|
+
class TreeFormatter
|
|
9
|
+
ARROW = "→" #: String
|
|
10
|
+
FALLBACK_ARROW = "⤷" #: String
|
|
11
|
+
|
|
12
|
+
SUCCESS_MARK = "✓" #: String
|
|
13
|
+
FAILURE_MARK = "✗" #: String
|
|
14
|
+
SKIPPED_MARK = "○" #: String
|
|
15
|
+
|
|
16
|
+
PASS_MODES = {
|
|
17
|
+
kwargs_splat: "**ctx",
|
|
18
|
+
single_value: "ctx",
|
|
19
|
+
transform: "transform",
|
|
20
|
+
fallback: "fallback"
|
|
21
|
+
}.freeze #: Hash[Symbol, String]
|
|
22
|
+
|
|
23
|
+
COLORS = {
|
|
24
|
+
green: "\e[32m",
|
|
25
|
+
red: "\e[31m",
|
|
26
|
+
yellow: "\e[33m",
|
|
27
|
+
cyan: "\e[36m",
|
|
28
|
+
dim: "\e[2m",
|
|
29
|
+
bold: "\e[1m",
|
|
30
|
+
reset: "\e[0m"
|
|
31
|
+
}.freeze #: Hash[Symbol, String]
|
|
32
|
+
|
|
33
|
+
# @rbs @color: bool
|
|
34
|
+
|
|
35
|
+
#: (?color: bool?) -> void
|
|
36
|
+
def initialize(color: nil)
|
|
37
|
+
@color = color.nil? ? detect_color_support : color
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (Trace) -> String
|
|
41
|
+
def format(trace)
|
|
42
|
+
lines = [] #: Array[String]
|
|
43
|
+
format_chain(trace, lines)
|
|
44
|
+
lines.join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
#: (Trace, Array[String], ?depth: Integer, ?is_fallback: bool) -> void
|
|
50
|
+
def format_chain(trace, lines, depth: 0, is_fallback: false)
|
|
51
|
+
indent = " " * depth
|
|
52
|
+
|
|
53
|
+
status = format_status(trace)
|
|
54
|
+
duration = format_duration(trace.duration_ms)
|
|
55
|
+
pass_info = format_pass_mode(trace)
|
|
56
|
+
|
|
57
|
+
op_name = trace.operation_name
|
|
58
|
+
if is_fallback
|
|
59
|
+
op_name = "#{colorize(FALLBACK_ARROW, :yellow)} #{op_name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
line = "#{indent}#{op_name} #{duration} #{status}"
|
|
63
|
+
|
|
64
|
+
if pass_info
|
|
65
|
+
line = "#{line} #{pass_info}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
extracted = trace.extracted_params
|
|
69
|
+
if extracted&.any?
|
|
70
|
+
params_str = extracted.join(", ")
|
|
71
|
+
line = "#{line} #{colorize("← [#{params_str}]", :dim)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
lines << line
|
|
75
|
+
|
|
76
|
+
if trace.exception
|
|
77
|
+
lines << "#{indent} #{colorize("└ Exception: #{trace.exception.class}: #{trace.exception.message}", :red)}"
|
|
78
|
+
elsif trace.failure? && trace.result.respond_to?(:failure)
|
|
79
|
+
failure_info = trace.result.failure
|
|
80
|
+
lines << "#{indent} #{colorize("└ Failure: #{failure_info.inspect}", :red)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
trace.children.each_with_index do |child, index|
|
|
84
|
+
is_child_fallback = child.fallback_used
|
|
85
|
+
|
|
86
|
+
if index == 0 || is_child_fallback
|
|
87
|
+
arrow = is_child_fallback ? FALLBACK_ARROW : ARROW
|
|
88
|
+
arrow_color = is_child_fallback ? :yellow : :dim
|
|
89
|
+
lines << "#{indent} #{colorize(arrow, arrow_color)}"
|
|
90
|
+
else
|
|
91
|
+
lines << "#{indent} #{colorize(ARROW, :dim)}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
format_chain(child, lines, depth: depth, is_fallback: is_child_fallback)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#: (Trace) -> String
|
|
99
|
+
def format_status(trace)
|
|
100
|
+
if trace.exception
|
|
101
|
+
colorize(FAILURE_MARK, :red)
|
|
102
|
+
elsif trace.failure?
|
|
103
|
+
colorize(FAILURE_MARK, :red)
|
|
104
|
+
else
|
|
105
|
+
colorize(SUCCESS_MARK, :green)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: (Float | Integer | nil) -> String
|
|
110
|
+
def format_duration(ms)
|
|
111
|
+
return colorize("[--ms]", :dim) unless ms
|
|
112
|
+
colorize("[#{ms}ms]", :dim)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
#: (Trace) -> String?
|
|
116
|
+
def format_pass_mode(trace)
|
|
117
|
+
return nil unless trace.pass_mode
|
|
118
|
+
mode_str = PASS_MODES[trace.pass_mode] || trace.pass_mode.to_s
|
|
119
|
+
colorize("(#{mode_str})", :cyan)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
#: (Exception?) -> String
|
|
123
|
+
def format_exception(exception)
|
|
124
|
+
return "" unless exception
|
|
125
|
+
colorize("(#{exception.class}: #{exception.message})", :red)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#: (String, Symbol) -> String
|
|
129
|
+
def colorize(text, color)
|
|
130
|
+
return text unless @color
|
|
131
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#: () -> bool
|
|
135
|
+
def detect_color_support
|
|
136
|
+
return false unless $stdout.respond_to?(:tty?)
|
|
137
|
+
$stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "instrumentation/trace"
|
|
5
|
+
require_relative "instrumentation/tree_formatter"
|
|
6
|
+
|
|
7
|
+
module TypedOperation
|
|
8
|
+
# Provides runtime tracing and debugging capabilities for operations.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# # Trace a single operation call
|
|
12
|
+
# result = MyOperation.explain(param: value)
|
|
13
|
+
# # Prints execution tree to stdout, returns the operation result
|
|
14
|
+
#
|
|
15
|
+
# # Trace with partial application
|
|
16
|
+
# result = MyOperation.with(partial: value).explain(rest: value)
|
|
17
|
+
#
|
|
18
|
+
# # Trace a block of code (captures all instrumented operations)
|
|
19
|
+
# TypedOperation::Instrumentation.explaining do
|
|
20
|
+
# MyOperation.call(params)
|
|
21
|
+
# AnotherOperation.call(other_params)
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# # Configure output and color
|
|
25
|
+
# TypedOperation::Instrumentation.with_output(my_io, color: false) do
|
|
26
|
+
# MyOperation.explain(param: value)
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
module Instrumentation
|
|
30
|
+
THREAD_KEY = :typed_operation_trace_stack #: Symbol
|
|
31
|
+
CHAIN_CONTEXT_KEY = :typed_operation_chain_context #: Symbol
|
|
32
|
+
OUTPUT_KEY = :typed_operation_output #: Symbol
|
|
33
|
+
COLOR_KEY = :typed_operation_color #: Symbol
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Execute a block with custom output and color settings.
|
|
37
|
+
#: [T] (IO, ?color: bool?) { () -> T } -> T
|
|
38
|
+
def with_output(io, color: nil, &block)
|
|
39
|
+
old_output = Thread.current[OUTPUT_KEY]
|
|
40
|
+
old_color = Thread.current[COLOR_KEY]
|
|
41
|
+
Thread.current[OUTPUT_KEY] = io
|
|
42
|
+
Thread.current[COLOR_KEY] = color
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
Thread.current[OUTPUT_KEY] = old_output
|
|
46
|
+
Thread.current[COLOR_KEY] = old_color
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Execute a block with tracing enabled, printing the trace tree afterward.
|
|
50
|
+
#: [T] () { () -> T } -> T
|
|
51
|
+
def explaining(&block)
|
|
52
|
+
trace_root = Trace.new(operation_class: BlockExecution, params: {})
|
|
53
|
+
push_trace(trace_root)
|
|
54
|
+
|
|
55
|
+
result = nil
|
|
56
|
+
exception = nil
|
|
57
|
+
begin
|
|
58
|
+
result = block.call
|
|
59
|
+
rescue => e
|
|
60
|
+
exception = e
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
trace_root.finish!(result: result, exception: exception)
|
|
64
|
+
pop_trace
|
|
65
|
+
|
|
66
|
+
formatter = TreeFormatter.new(color: color)
|
|
67
|
+
trace_root.children.each do |child_trace|
|
|
68
|
+
output.puts formatter.format(child_trace)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
raise exception if exception
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: () -> IO
|
|
76
|
+
def output
|
|
77
|
+
Thread.current[OUTPUT_KEY] || $stdout
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: () -> bool
|
|
81
|
+
def color
|
|
82
|
+
val = Thread.current[COLOR_KEY]
|
|
83
|
+
val.nil? || val
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#: () -> Trace?
|
|
87
|
+
def current_trace
|
|
88
|
+
stack = Thread.current[THREAD_KEY]
|
|
89
|
+
stack&.last
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#: () -> bool
|
|
93
|
+
def tracing?
|
|
94
|
+
!current_trace.nil?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
#: (Trace) -> void
|
|
98
|
+
def push_trace(trace)
|
|
99
|
+
Thread.current[THREAD_KEY] ||= []
|
|
100
|
+
parent = current_trace
|
|
101
|
+
parent&.add_child(trace)
|
|
102
|
+
Thread.current[THREAD_KEY].push(trace)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
#: () -> Trace?
|
|
106
|
+
def pop_trace
|
|
107
|
+
stack = Thread.current[THREAD_KEY]
|
|
108
|
+
stack&.pop
|
|
109
|
+
Thread.current[THREAD_KEY] = nil if stack&.empty?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
#: () -> void
|
|
113
|
+
def clear_trace!
|
|
114
|
+
Thread.current[THREAD_KEY] = nil
|
|
115
|
+
Thread.current[CHAIN_CONTEXT_KEY] = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
#: (pass_mode: Symbol, ?extracted_params: Array[Symbol]?, ?fallback_used: bool) -> void
|
|
119
|
+
def set_chain_context(pass_mode:, extracted_params: nil, fallback_used: false)
|
|
120
|
+
Thread.current[CHAIN_CONTEXT_KEY] = {
|
|
121
|
+
pass_mode: pass_mode,
|
|
122
|
+
extracted_params: extracted_params,
|
|
123
|
+
fallback_used: fallback_used
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: () -> Hash[Symbol, untyped]?
|
|
128
|
+
def take_chain_context
|
|
129
|
+
ctx = Thread.current[CHAIN_CONTEXT_KEY]
|
|
130
|
+
Thread.current[CHAIN_CONTEXT_KEY] = nil
|
|
131
|
+
ctx
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Placeholder class for block-based tracing
|
|
136
|
+
class BlockExecution; end
|
|
137
|
+
|
|
138
|
+
# Module to be prepended to operation classes to enable tracing.
|
|
139
|
+
# This is automatically included when using `explain`.
|
|
140
|
+
module Traceable
|
|
141
|
+
#: () -> untyped
|
|
142
|
+
def execute_operation
|
|
143
|
+
return super unless Instrumentation.tracing?
|
|
144
|
+
|
|
145
|
+
trace = Trace.new(
|
|
146
|
+
operation_class: self.class,
|
|
147
|
+
params: extract_params_for_trace
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Apply chain context if available
|
|
151
|
+
if (chain_ctx = Instrumentation.take_chain_context)
|
|
152
|
+
trace.pass_mode = chain_ctx[:pass_mode]
|
|
153
|
+
trace.extracted_params = chain_ctx[:extracted_params]
|
|
154
|
+
trace.fallback_used = chain_ctx[:fallback_used]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Instrumentation.push_trace(trace)
|
|
158
|
+
|
|
159
|
+
result = nil
|
|
160
|
+
exception = nil
|
|
161
|
+
begin
|
|
162
|
+
result = super
|
|
163
|
+
rescue => e
|
|
164
|
+
exception = e
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
trace.finish!(result: result, exception: exception)
|
|
168
|
+
Instrumentation.pop_trace
|
|
169
|
+
|
|
170
|
+
raise exception if exception
|
|
171
|
+
result
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
#: () -> Hash[Symbol, untyped]
|
|
177
|
+
def extract_params_for_trace
|
|
178
|
+
self.class.literal_properties.keys.each_with_object({}) do |key, hash|
|
|
179
|
+
hash[key] = send(key)
|
|
180
|
+
end
|
|
181
|
+
rescue
|
|
182
|
+
{}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Class methods added to operations for the `explain` entry point.
|
|
187
|
+
module Explainable
|
|
188
|
+
#: (*untyped, **untyped) ?{ () -> untyped } -> untyped
|
|
189
|
+
def explain(*args, **kwargs, &block)
|
|
190
|
+
trace = Trace.new(operation_class: self, params: kwargs.merge(positional: args))
|
|
191
|
+
Instrumentation.push_trace(trace)
|
|
192
|
+
|
|
193
|
+
result = nil
|
|
194
|
+
exception = nil
|
|
195
|
+
begin
|
|
196
|
+
result = call(*args, **kwargs, &block)
|
|
197
|
+
rescue => e
|
|
198
|
+
exception = e
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
trace.finish!(result: result, exception: exception)
|
|
202
|
+
Instrumentation.pop_trace
|
|
203
|
+
|
|
204
|
+
formatter = TreeFormatter.new(color: Instrumentation.color)
|
|
205
|
+
Instrumentation.output.puts formatter.format(trace)
|
|
206
|
+
|
|
207
|
+
Instrumentation.clear_trace!
|
|
208
|
+
|
|
209
|
+
raise exception if exception
|
|
210
|
+
result
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|