tapping_device 0.5.1 → 0.5.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,10 +1,20 @@
1
1
  require "active_record"
2
+ require "active_support/core_ext/module/introspection"
3
+ require "pry" # for using Method#source
4
+
2
5
  require "tapping_device/version"
3
6
  require "tapping_device/manageable"
4
7
  require "tapping_device/payload"
5
- require "tapping_device/output_payload"
8
+ require "tapping_device/output"
6
9
  require "tapping_device/trackable"
10
+ require "tapping_device/configurable"
7
11
  require "tapping_device/exceptions"
12
+ require "tapping_device/method_hijacker"
13
+ require "tapping_device/trackers/initialization_tracker"
14
+ require "tapping_device/trackers/passed_tracker"
15
+ require "tapping_device/trackers/association_call_tracker"
16
+ require "tapping_device/trackers/method_call_tracker"
17
+ require "tapping_device/trackers/mutation_tracker"
8
18
 
9
19
  class TappingDevice
10
20
 
@@ -18,48 +28,17 @@ class TappingDevice
18
28
 
19
29
  extend Manageable
20
30
 
31
+ include Configurable
32
+ include Output::Helpers
33
+
21
34
  def initialize(options = {}, &block)
22
35
  @block = block
23
36
  @output_block = nil
24
- @options = process_options(options)
37
+ @options = process_options(options.dup)
25
38
  @calls = []
26
39
  @disabled = false
27
40
  @with_condition = nil
28
- self.class.devices << self
29
- end
30
-
31
- def tap_init!(klass)
32
- raise "argument should be a class, got #{klass}" unless klass.is_a?(Class)
33
- track(klass, condition: :tap_init?) do |payload|
34
- payload[:return_value] = payload[:receiver]
35
- payload[:receiver] = klass
36
- end
37
- end
38
-
39
- def tap_on!(object)
40
- track(object, condition: :tap_on?)
41
- end
42
-
43
- def tap_passed!(object)
44
- track(object, condition: :tap_passed?)
45
- end
46
-
47
- def tap_assoc!(record)
48
- raise "argument should be an instance of ActiveRecord::Base" unless record.is_a?(ActiveRecord::Base)
49
- track(record, condition: :tap_associations?)
50
- end
51
-
52
- def and_print(payload_method = nil, &block)
53
- @output_block =
54
- if block
55
- -> (output_payload) { puts(block.call(output_payload)) }
56
- elsif payload_method
57
- -> (output_payload) { puts(output_payload.send(payload_method)) }
58
- else
59
- raise "need to provide either a payload method name or a block"
60
- end
61
-
62
- self
41
+ TappingDevice.devices << self
63
42
  end
64
43
 
65
44
  def with(&block)
@@ -72,7 +51,7 @@ class TappingDevice
72
51
 
73
52
  def stop!
74
53
  @disabled = true
75
- self.class.delete_device(self)
54
+ TappingDevice.delete_device(self)
76
55
  end
77
56
 
78
57
  def stop_when(&block)
@@ -95,57 +74,48 @@ class TappingDevice
95
74
  options[:descendants]
96
75
  end
97
76
 
98
- private
99
-
100
- def track(object, condition:, &payload_block)
77
+ def track(object)
101
78
  @target = object
102
- @trace_point = TracePoint.new(options[:event_type]) do |tp|
103
- if send(condition, object, tp)
104
- filepath, line_number = get_call_location(tp)
105
-
106
- next if should_be_skipped_by_paths?(filepath)
79
+ validate_target!
107
80
 
108
- payload = build_payload(tp: tp, filepath: filepath, line_number: line_number, &payload_block)
81
+ MethodHijacker.new(@target).hijack_methods! if options[:hijack_attr_methods]
109
82
 
110
- next unless with_condition_satisfied?(payload)
111
-
112
- # skip TappingDevice related calls
113
- if Module.respond_to?(:module_parents)
114
- next if payload.defined_class.module_parents.include?(TappingDevice)
115
- else
116
- next if payload.defined_class.parents.include?(TappingDevice)
117
- end
83
+ @trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
84
+ record_call!(payload)
118
85
 
119
- record_call!(payload)
120
-
121
- stop_if_condition_fulfilled(payload)
122
- end
86
+ stop_if_condition_fulfilled!(payload)
123
87
  end
124
88
 
125
- @trace_point.enable unless self.class.suspend_new
89
+ @trace_point.enable unless TappingDevice.suspend_new
126
90
 
127
91
  self
128
92
  end
129
93
 
130
- def get_call_location(tp, padding: 0)
131
- caller(get_trace_index(tp) + padding).first.split(":")[0..1]
132
- end
94
+ private
133
95
 
134
- def get_trace_index(tp)
135
- if tp.event == :c_call
136
- C_CALLER_START_POINT
137
- else
138
- CALLER_START_POINT
96
+ def build_minimum_trace_point(event_type:)
97
+ TracePoint.new(*event_type) do |tp|
98
+ next unless filter_condition_satisfied?(tp)
99
+
100
+ filepath, line_number = get_call_location(tp)
101
+ payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
102
+
103
+ unless @options[:force_recording]
104
+ next if is_tapping_device_call?(tp)
105
+ next if should_be_skipped_by_paths?(filepath)
106
+ next unless with_condition_satisfied?(payload)
107
+ next if payload.is_private_call? && @options[:ignore_private]
108
+ next if !payload.is_private_call? && @options[:only_private]
109
+ end
110
+
111
+ yield(payload)
139
112
  end
140
113
  end
141
114
 
142
- def get_traces(tp)
143
- if with_trace_to = options[:with_trace_to]
144
- trace_index = get_trace_index(tp)
145
- caller[trace_index..(trace_index + with_trace_to)]
146
- else
147
- []
148
- end
115
+ def validate_target!; end
116
+
117
+ def filter_condition_satisfied?(tp)
118
+ false
149
119
  end
150
120
 
151
121
  # this needs to be placed upfront so we can exclude noise before doing more work
@@ -154,8 +124,24 @@ class TappingDevice
154
124
  (options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
155
125
  end
156
126
 
127
+ def is_tapping_device_call?(tp)
128
+ if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
129
+ return true
130
+ end
131
+
132
+ if Module.respond_to?(:module_parents)
133
+ tp.defined_class.module_parents.include?(TappingDevice)
134
+ elsif Module.respond_to?(:parents)
135
+ tp.defined_class.parents.include?(TappingDevice)
136
+ end
137
+ end
138
+
139
+ def with_condition_satisfied?(payload)
140
+ @with_condition.blank? || @with_condition.call(payload)
141
+ end
142
+
157
143
  def build_payload(tp:, filepath:, line_number:)
158
- payload = Payload.init({
144
+ Payload.init({
159
145
  target: @target,
160
146
  receiver: tp.self,
161
147
  method_name: tp.callee_id,
@@ -166,60 +152,38 @@ class TappingDevice
166
152
  line_number: line_number,
167
153
  defined_class: tp.defined_class,
168
154
  trace: get_traces(tp),
155
+ is_private_call?: tp.defined_class.private_method_defined?(tp.callee_id),
169
156
  tp: tp
170
157
  })
171
-
172
- yield(payload) if block_given?
173
-
174
- payload
175
- end
176
-
177
- def tap_init?(klass, tp)
178
- receiver = tp.self
179
- method_name = tp.callee_id
180
-
181
- if klass.ancestors.include?(ActiveRecord::Base)
182
- method_name == :new && receiver.ancestors.include?(klass)
183
- else
184
- method_name == :initialize && receiver.is_a?(klass)
185
- end
186
158
  end
187
159
 
188
- def tap_on?(object, tp)
189
- is_from_target?(object, tp)
160
+ def get_method_object_from(target, method_name)
161
+ Object.instance_method(:method).bind(target).call(method_name)
162
+ rescue NameError
163
+ # if any part of the program uses Refinement to extend its methods
164
+ # we might still get NoMethodError when trying to get that method outside the scope
165
+ nil
190
166
  end
191
167
 
192
- def tap_associations?(object, tp)
193
- return false unless tap_on?(object, tp)
194
-
195
- model_class = object.class
196
- associations = model_class.reflections
197
- associations.keys.include?(tp.callee_id.to_s)
168
+ def get_call_location(tp, padding: 0)
169
+ caller(get_trace_index(tp) + padding).first.split(":")[0..1]
198
170
  end
199
171
 
200
- def tap_passed?(object, tp)
201
- # we don't care about calls from the device instance or helper methods
202
- return false if is_from_target?(self, tp)
203
- return false if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
204
-
205
- collect_arguments(tp).values.any? do |value|
206
- # during comparison, Ruby might perform data type conversion like calling `to_sym` on the value
207
- # but not every value supports every conversion methods
208
- object == value rescue false
172
+ def get_trace_index(tp)
173
+ if tp.event == :c_call
174
+ C_CALLER_START_POINT
175
+ else
176
+ CALLER_START_POINT
209
177
  end
210
- rescue
211
- false
212
178
  end
213
179
 
214
- def get_method_object_from(target, method_name)
215
- target.method(method_name)
216
- rescue ArgumentError
217
- method_method = Object.method(:method).unbind
218
- method_method.bind(target).call(method_name)
219
- rescue NameError
220
- # if any part of the program uses Refinement to extend its methods
221
- # we might still get NoMethodError when trying to get that method outside the scope
222
- nil
180
+ def get_traces(tp)
181
+ if with_trace_to = options[:with_trace_to]
182
+ trace_index = get_trace_index(tp)
183
+ caller[trace_index..(trace_index + with_trace_to)]
184
+ else
185
+ []
186
+ end
223
187
  end
224
188
 
225
189
  def collect_arguments(tp)
@@ -236,29 +200,28 @@ class TappingDevice
236
200
  end
237
201
 
238
202
  def process_options(options)
239
- options[:filter_by_paths] ||= []
240
- options[:exclude_by_paths] ||= []
241
- options[:with_trace_to] ||= 50
242
- options[:root_device] ||= self
243
- options[:event_type] ||= :return
203
+ options[:filter_by_paths] ||= config[:filter_by_paths]
204
+ options[:exclude_by_paths] ||= config[:exclude_by_paths]
205
+ options[:with_trace_to] ||= config[:with_trace_to]
206
+ options[:event_type] ||= config[:event_type]
207
+ options[:hijack_attr_methods] ||= config[:hijack_attr_methods]
208
+ options[:track_as_records] ||= config[:track_as_records]
209
+ options[:ignore_private] ||= config[:ignore_private]
210
+ options[:only_private] ||= config[:only_private]
211
+ # for debugging the gem more easily
212
+ options[:force_recording] ||= false
213
+
244
214
  options[:descendants] ||= []
245
- options[:track_as_records] ||= false
215
+ options[:root_device] ||= self
246
216
  options
247
217
  end
248
218
 
249
- def stop_if_condition_fulfilled(payload)
250
- if @stop_when&.call(payload)
251
- stop!
252
- root_device.stop!
253
- end
254
- end
255
-
256
- def is_from_target?(object, tp)
219
+ def is_from_target?(tp)
257
220
  comparsion = tp.self
258
- is_the_same_record?(object, comparsion) || object.__id__ == comparsion.__id__
221
+ is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
259
222
  end
260
223
 
261
- def is_the_same_record?(target, comparsion)
224
+ def is_the_same_record?(comparsion)
262
225
  return false unless options[:track_as_records]
263
226
  if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
264
227
  primary_key = target.class.primary_key
@@ -269,7 +232,7 @@ class TappingDevice
269
232
  def record_call!(payload)
270
233
  return if @disabled
271
234
 
272
- @output_block.call(OutputPayload.init(payload)) if @output_block
235
+ write_output!(payload) if @output_writer
273
236
 
274
237
  if @block
275
238
  root_device.calls << @block.call(payload)
@@ -278,7 +241,18 @@ class TappingDevice
278
241
  end
279
242
  end
280
243
 
281
- def with_condition_satisfied?(payload)
282
- @with_condition.blank? || @with_condition.call(payload)
244
+ def write_output!(payload)
245
+ @output_writer.write!(payload)
246
+ end
247
+
248
+ def stop_if_condition_fulfilled!(payload)
249
+ if @stop_when&.call(payload)
250
+ stop!
251
+ root_device.stop!
252
+ end
253
+ end
254
+
255
+ def config
256
+ TappingDevice.config
283
257
  end
284
258
  end
@@ -0,0 +1,27 @@
1
+ require "active_support/configurable"
2
+ require "active_support/concern"
3
+
4
+ class TappingDevice
5
+ module Configurable
6
+ extend ActiveSupport::Concern
7
+
8
+ DEFAULTS = {
9
+ filter_by_paths: [],
10
+ exclude_by_paths: [],
11
+ with_trace_to: 50,
12
+ event_type: :return,
13
+ hijack_attr_methods: false,
14
+ track_as_records: false,
15
+ ignore_private: false,
16
+ only_private: false
17
+ }.merge(TappingDevice::Output::DEFAULT_OPTIONS)
18
+
19
+ included do
20
+ include ActiveSupport::Configurable
21
+
22
+ DEFAULTS.each do |key, value|
23
+ config[key] = value
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,4 +1,16 @@
1
1
  class TappingDevice
2
2
  class Exception < StandardError
3
3
  end
4
+
5
+ class NotAnActiveRecordInstanceError < Exception
6
+ def initialize(object)
7
+ super("target object should be an instance of ActiveRecord::Base, got #{object}")
8
+ end
9
+ end
10
+
11
+ class NotAClassError < Exception
12
+ def initialize(object)
13
+ super("target object should be a class, got #{object}")
14
+ end
15
+ end
4
16
  end
@@ -0,0 +1,51 @@
1
+ class TappingDevice
2
+ class MethodHijacker
3
+ attr_reader :target
4
+
5
+ def initialize(target)
6
+ @target = target
7
+ end
8
+
9
+ def hijack_methods!
10
+ target.methods.each do |method_name|
11
+ if is_writer_method?(method_name)
12
+ redefine_writer_method!(method_name)
13
+ elsif is_reader_method?(method_name)
14
+ redefine_reader_method!(method_name)
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def is_writer_method?(method_name)
22
+ has_definition_source?(method_name) && method_name.match?(/\w+=/) && target.method(method_name).source.match?(/attr_writer|attr_accessor/)
23
+ end
24
+
25
+ def is_reader_method?(method_name)
26
+ has_definition_source?(method_name) && target.method(method_name).source.match?(/attr_reader|attr_accessor/)
27
+ end
28
+
29
+ def has_definition_source?(method_name)
30
+ target.method(method_name).source_location
31
+ end
32
+
33
+ def redefine_writer_method!(method_name)
34
+ ivar_name = "@#{method_name.to_s.sub("=", "")}"
35
+
36
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
37
+ def #{method_name}(val)
38
+ #{ivar_name} = val
39
+ end
40
+ RUBY
41
+ end
42
+
43
+ def redefine_reader_method!(method_name)
44
+ target.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
45
+ def #{method_name}
46
+ @#{method_name}
47
+ end
48
+ RUBY
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ require "tapping_device/output/payload"
2
+ require "tapping_device/output/writer"
3
+ require "tapping_device/output/stdout_writer"
4
+ require "tapping_device/output/file_writer"
5
+
6
+ class TappingDevice
7
+ module Output
8
+ DEFAULT_OPTIONS = {
9
+ inspect: false,
10
+ colorize: true,
11
+ log_file: "/tmp/tapping_device.log"
12
+ }
13
+
14
+ module Helpers
15
+ def and_write(payload_method = nil, options: {}, &block)
16
+ and_output(payload_method, options: options, writer_klass: FileWriter, &block)
17
+ end
18
+
19
+ def and_print(payload_method = nil, options: {}, &block)
20
+ and_output(payload_method, options: options, writer_klass: StdoutWriter, &block)
21
+ end
22
+
23
+ def and_output(payload_method = nil, options: {}, writer_klass:, &block)
24
+ output_block = generate_output_block(payload_method, block)
25
+ @output_writer = writer_klass.new(options, output_block)
26
+ self
27
+ end
28
+
29
+ private
30
+
31
+ def generate_output_block(payload_method, block)
32
+ if block
33
+ block
34
+ elsif payload_method
35
+ -> (output_payload, output_options) { output_payload.send(payload_method, output_options) }
36
+ else
37
+ raise "need to provide either a payload method name or a block"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end