bond-spy 0.1.0

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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.yardopts +1 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE +27 -0
  7. data/README.rst +65 -0
  8. data/Rakefile +6 -0
  9. data/bin/bond_reconcile.py +385 -0
  10. data/bin/setup +5 -0
  11. data/bond.gemspec +36 -0
  12. data/lib/bond.rb +469 -0
  13. data/lib/bond/spec_helper.rb +32 -0
  14. data/lib/bond/targetable.rb +210 -0
  15. data/lib/bond/version.rb +3 -0
  16. data/spec/bond_spec.rb +158 -0
  17. data/spec/bond_targetable_spec.rb +202 -0
  18. data/spec/spec_helper.rb +2 -0
  19. data/spec/test_observations/.gitignore +2 -0
  20. data/spec/test_observations/bond_spec/Bond_with_agents_should_call_doers_before_returning_result.json +14 -0
  21. data/spec/test_observations/bond_spec/Bond_with_agents_should_call_the_function_passed_as_result_if_it_is_callable.json +23 -0
  22. data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_a_single_doer_if_filter_criteria_are_met.json +10 -0
  23. data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_multiple_doers.json +13 -0
  24. data/spec/test_observations/bond_spec/Bond_with_agents_should_not_call_doers_of_overriden_agents.json +8 -0
  25. data/spec/test_observations/bond_spec/Bond_with_agents_should_override_old_agents_with_newer_agents.json +0 -0
  26. data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_an_exception_if_specified_by_agent.json +9 -0
  27. data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_the_result_of_the_value_passed_to_exception_if_callable.json +10 -0
  28. data/spec/test_observations/bond_spec/Bond_with_agents_should_work_with_multiple_agents_for_different_spy_points.json +23 -0
  29. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_override_old_agents_with_newer_agents_unless_theott8glo1xn.json +22 -0
  30. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_combinations_of_filters.json +37 -0
  31. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_function_filters.json +16 -0
  32. data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_single_key_value_filters_of_all_types.json +121 -0
  33. data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_nested_hashes_and_arrays_with_hash_sorting.json +31 -0
  34. data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_with_a_spy_point_name.json +12 -0
  35. data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_without_a_spy_point_name.json +10 -0
  36. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_continue_is_returned.json +10 -0
  37. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_none_is_returned.json +14 -0
  38. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_passes_through_blocks.json +10 -0
  39. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_returns_nil__and_mocks__when_an_agent_returns_nil.json +11 -0
  40. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_private_methods.json +6 -0
  41. data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_protected_methods.json +6 -0
  42. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_class_method.json +7 -0
  43. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_method_with_variabl19nhijeqoo.json +13 -0
  44. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_a6qc3d4el92.json +33 -0
  45. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_aott8glo1xn.json +20 -0
  46. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_required_andbcgjq06had.json +17 -0
  47. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_normal_method.json +7 -0
  48. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_all_optional_keyword_arguments.json +10 -0
  49. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_variable_keyword_arguments.json +22 -0
  50. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_changes_the_spy_point_naj4gnwvcu8n.json +5 -0
  51. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_errors_when_mocking_is_r9j7wklng0z.json +6 -0
  52. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_ignores_excluded_keys.json +10 -0
  53. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_mocks_when_one_is_specified.json +10 -0
  54. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_w19nhijeqoo.json +10 -0
  55. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_ww8esw1qdxc.json +10 -0
  56. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_included_module_methods.json +6 -0
  57. data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_module_methods.json +7 -0
  58. data/tutorials/binary_search_tree/bst.rb +120 -0
  59. data/tutorials/binary_search_tree/bst_spec.rb +82 -0
  60. data/tutorials/binary_search_tree/run_tests.sh +4 -0
  61. data/tutorials/binary_search_tree/test_observations/.gitignore +2 -0
  62. data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_with_Bond.json +20 -0
  63. data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_without_Bond.json +3 -0
  64. data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_correctly_delete_nodes_from_the_BST.json +29 -0
  65. data/tutorials/heat_watcher/heat_watcher.rb +107 -0
  66. data/tutorials/heat_watcher/heat_watcher_spec.rb +116 -0
  67. data/tutorials/heat_watcher/run_tests.sh +4 -0
  68. data/tutorials/heat_watcher/test_observations/.gitignore +2 -0
  69. data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_critical_errors.json +142 -0
  70. data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_warnings_and_switch_back_to_OK_status.json +132 -0
  71. metadata +211 -0
data/bin/setup ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
data/bond.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ require_relative 'lib/bond/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'bond-spy'
5
+ spec.version = Bond::VERSION
6
+ spec.date = '2016-01-28'
7
+
8
+ spec.authors = ['George Necula', 'Erik Krogen']
9
+ spec.email = ['necula@cs.berkeley.edu', 'erikkrogen@gmail.com']
10
+ spec.license = 'BSD-2-Clause-FreeBSD'
11
+
12
+ spec.summary = 'A spy-based testing framework'
13
+ spec.homepage = 'http://github.com/necula01/bond'
14
+ spec.description = <<-EOF
15
+ Bond is a small library that can be used to spy values and mock functions
16
+ during tests. Spying is a replacement for writing the assertEquals in your
17
+ test, which are tedious to write and even more tedious to update when your
18
+ test setup or code inevitably changes. With Bond, you separate what is
19
+ being verified, e.g., the variable named output, from what value it should
20
+ have. This way you can quickly spy several variables, even have structured
21
+ values such as lists or dictionaries, and these values are saved into an
22
+ observation log that is saved for future reference. If the test observations
23
+ are different you have the option to interact with a console or visual tool
24
+ to see what has changed, and whether the reference set of observations need
25
+ to be updated.
26
+ EOF
27
+
28
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }
29
+ spec.test_files = `git ls-files -z -- spec`.split("\x0")
30
+ spec.bindir = 'bin'
31
+
32
+ spec.required_ruby_version = '>= 2.1'
33
+ spec.add_development_dependency 'bundler', '~> 1.10'
34
+ spec.add_development_dependency 'rake', '~> 10.0'
35
+ spec.add_development_dependency 'rspec', '~> 3.0'
36
+ end
data/lib/bond.rb ADDED
@@ -0,0 +1,469 @@
1
+ require 'singleton'
2
+ require 'json'
3
+ require 'fileutils'
4
+ require 'shellwords'
5
+
6
+ # Singleton class providing the core functionality of Bond. You will generally
7
+ # access this through the {BondTargetable#bond} method exported to you when you
8
+ # `extend` BondTargetable, but it can also be accessed (e.g. for functions not
9
+ # contained in a class/module) via `Bond.instance`.
10
+ class Bond
11
+ include Singleton
12
+ # TODO ETK make this able to use other test frameworks as well
13
+
14
+ # Maximum number of characters to allow in any test file name.
15
+ # File names which would be longer than this will be truncated
16
+ # to 10 fewer characters than the max, and a hash of the full
17
+ # name will be appended to uniquely identify the file.
18
+ MAX_FILE_NAME_LENGTH = 100
19
+
20
+ # Returns true if Bond is currently active (in testing mode), else false.
21
+ # If this returns false, you can safely assume that calls to {#spy} will
22
+ # have no effect.
23
+ def active?
24
+ !@current_test.nil?
25
+ end
26
+
27
+ # Change the settings for Bond, overriding anything which was previously set by
28
+ # {#start_test}. Accepts any of the keyword arguments that {#start_test} does
29
+ # except for `test_name`, which cannot be changed.
30
+ # @param (see #start_test)
31
+ def settings(spy_groups: nil, observation_directory: nil, reconcile: nil)
32
+ raise 'not yet implemented' unless spy_groups.nil? # TODO spy_groups
33
+ @observation_directory = observation_directory unless observation_directory.nil?
34
+ @reconcile = reconcile unless @reconcile.nil?
35
+ end
36
+
37
+ # Enable testing for Bond. When using RSpec, this will be called automatically
38
+ # for you when you `include_context :bond`, to which you can pass all of the
39
+ # same keyword arguments as `start_test`.
40
+ # @param rspec_test The current test that is being run through RSpec.
41
+ # @param test_name [String] The name of the current test. If not provided,
42
+ # the default is the test file concatenated with the full description
43
+ # string of the RSpec test, e.g. for a test within my_class_spec.rb that
44
+ # appeared as:
45
+ #
46
+ # ```
47
+ # describe MyClass do
48
+ # include_context :bond, observation_directory: 'spec/observations'
49
+ #
50
+ # context 'when nothing is wrong' do
51
+ # it 'should work!' do
52
+ # ... test code ...
53
+ # # `include_context :bond` automatically exports a `bond` variable
54
+ # # for you to access for e.g. `spy` and `deploy_agent`
55
+ # bond.spy('spy_point', key: value, ...)
56
+ # ... test code ...
57
+ # end
58
+ # end
59
+ # end
60
+ # ```
61
+ #
62
+ # `test_name` would be 'my_class_spec.MyClass_when_nothing_is_wrong_should_work_'
63
+ # (note that all non-alphanumeric characters except periods are replaced with
64
+ # an underscore).
65
+ # @param spy_groups NOT YET IMPLEMENTED.
66
+ # @param observation_directory [String] Path to the directory where
67
+ # the observations for this test should be stored. The default is
68
+ # a 'test_observations' directory located in the same directory
69
+ # as the file containing the current test. Test observations will
70
+ # be stored within this directory. Any hierarchy (as specified by .)
71
+ # in the test name becomes a directory hierarchy, e.g. a test name of
72
+ # 'bond.my_tests.test_name' would be stored at
73
+ # '`{observation_directory}`/bond/my_tests/test_name.json'
74
+ # @param reconcile The action to take when there are differences found between
75
+ # the reference versions of test output and the current test output.
76
+ # Should be one of:
77
+ #
78
+ # - **`:console`** - interactive console prompts to decide what to merge (default)
79
+ # - `:abort` - don't accept any new changes
80
+ # - `:accept` - accept all new changes
81
+ # - `:kdiff3` - use the kdiff3 graphical merge tool to reconcile differences
82
+ #
83
+ def start_test(rspec_test, test_name: nil, spy_groups: nil,
84
+ observation_directory: nil, reconcile: nil)
85
+
86
+ @observations = []
87
+ @spy_agents = Hash.new { |hash, key|
88
+ hash[key] = []
89
+ }
90
+ @observation_directory = nil
91
+ @current_test = rspec_test
92
+ @reconcile = reconcile
93
+
94
+ if test_name.nil?
95
+ test_file = @current_test.metadata[:file_path]
96
+ # TODO ETK allow other characters besides alphanumeric?
97
+ @test_name = File.basename(test_file, File.extname(test_file)) + '.' +
98
+ @current_test.metadata[:full_description].gsub(/[^A-z0-9.]/, '_')
99
+ else
100
+ @test_name = test_name
101
+ end
102
+
103
+ settings(spy_groups: spy_groups, observation_directory: observation_directory, reconcile: reconcile)
104
+ end
105
+
106
+ # The main entrypoint, used to observe some program state. If Bond is not active,
107
+ # does nothing. Observes all keyword arguments within `observation`, recording them
108
+ # to be written out to a file at the end of the current test. A deep copy
109
+ # of the arguments is made, all hashes (at any level of nesting) are sorted, and
110
+ # any object that is not an Array or Hash will have its `Object#clone` method called.
111
+ # If it is not cloneable (`clone` throws an error), the original object will be used.
112
+ # The arguments are then JSON-serialized. For any object which has a `to_json` method,
113
+ # this will be called to serialize it. Note that `to_json` takes one argument which
114
+ # is a JSON::Ext::Generator::State object; you should pass this object into any `to_json`
115
+ # calls you make within your `to_json` function.
116
+ # @param spy_point_name [#to_s] The name of this spy point. Will be used to subsequently
117
+ # refer to this point for, e.g., {#deploy_agent}. This name also gets printed as
118
+ # part of the observation with the key `__spy_point__`.
119
+ # @param observation Keyword arguments which should be observed.
120
+ # @return if an agent has been set for this spy point, this will return whatever value
121
+ # is specified by that agent. Otherwise, returns `:agent_result_none`.
122
+ def spy(spy_point_name=nil, **observation)
123
+ return :agent_result_none unless active? # If we're not testing, don't do anything
124
+
125
+ spy_point_name = spy_point_name.nil? ? nil : spy_point_name.to_s
126
+
127
+ observation[:__spy_point__] = spy_point_name unless spy_point_name.nil?
128
+ observation = deep_clone_sort_hashes(observation)
129
+ active_agent = spy_point_name.nil? ? nil : @spy_agents[spy_point_name].find { |agent| agent.process?(observation) }
130
+
131
+ res = :agent_result_none
132
+ begin
133
+ unless active_agent.nil?
134
+ active_agent.do(observation)
135
+ res = active_agent.result(observation)
136
+ end
137
+ ensure
138
+ formatted = format_observation(observation, active_agent)
139
+ @observations <<= formatted
140
+ #TODO ETK printing
141
+ puts "Observing: #{formatted} #{", returning <#{res.to_s}>" if res != :agent_result_none}"
142
+ end
143
+
144
+ res
145
+ end
146
+
147
+ # Deploy an agent to watch a specific spy point. The most recently deployed agent
148
+ # will always take precedence over any previous agents for a given spy point.
149
+ # However, if the most recent agent does not apply to the current observation
150
+ # (because its filters do not match), the next most recent agent will be used,
151
+ # and so on. At most one agent will be applied.
152
+ # @param spy_point_name [#to_s] The name of the spy point for which to deploy this agent
153
+ # @param (see SpyAgent#initialize)
154
+ def deploy_agent(spy_point_name, **opts)
155
+ raise 'You must enable testing before using deploy_agent' unless active?
156
+ spy_point_name = spy_point_name.to_s
157
+ @spy_agents[spy_point_name] = @spy_agents[spy_point_name].unshift(SpyAgent.new(**opts))
158
+ end
159
+
160
+ private
161
+
162
+ # Clean up from the current test. Marks Bond as no longer active, and saves
163
+ # all of the current observations to a file specified by {#observation_file_name}.
164
+ # Creates whatever directory structure necessary for this, and adds a .gitignore
165
+ # file to ignore Bond's temporary output (if one does not already exist). Then begins
166
+ # the reconciliation process. If the new observations are different
167
+ # from the reference files, reconciles them using whatever means specified
168
+ # by the `reconcile` setting.
169
+ # @return If the test failed, returns `:test_fail`. If reconciliation fails
170
+ # (new changes are not accepted), returns `:bond_fail`. Else returns `:pass`
171
+ def finish_test
172
+ fname = observation_file_name
173
+ fdir = File.dirname(fname)
174
+ unless File.directory?(fdir)
175
+ FileUtils.mkdir_p(fdir)
176
+ top_git_ignore = File.join(observation_directory, '.gitignore')
177
+ unless File.file?(top_git_ignore)
178
+ # TODO ETK make this configurable in case you don't use git?
179
+ File.open(top_git_ignore, 'w') do |outfile|
180
+ outfile.print("*_now.json\n*.diff\n")
181
+ end
182
+ end
183
+ end
184
+
185
+ test_fail = !@current_test.exception.nil?
186
+
187
+ ref_file = fname + '.json'
188
+ cur_file = fname + '_now.json'
189
+ File.delete(cur_file) if File.exists?(cur_file)
190
+ save_observations(cur_file)
191
+
192
+ reconcile_result = reconcile_observations(ref_file, cur_file,
193
+ test_fail ? 'Test had failure(s)!' : nil)
194
+ return :test_fail if test_fail
195
+ return reconcile_result
196
+ ensure
197
+ @current_test = nil
198
+ end
199
+
200
+ # Reconcile observations, for now using an external Python script.
201
+ # Depending on the `reconcile` setting, will take action to reconcile the differences.
202
+ # @param ref_file [String] Path to the accepted/reference test output.
203
+ # If this does not exist, it will be treated as an empty file.
204
+ # @param cur_file [String] Path to the current test output.
205
+ # @param no_save [nil, String] If not `nil`, `ref_file` will *not* be overwritten
206
+ # and the string will be displayed as the reason why saving is not allowed.
207
+ # @return `:pass` if the reconciliation succeeds, else `:bond_fail`
208
+ def reconcile_observations(ref_file, cur_file, no_save=nil)
209
+ bond_reconcile_script = File.absolute_path(File.join(File.dirname(File.dirname(__FILE__)), 'bin', 'bond_reconcile.py'))
210
+ unless File.exists?(bond_reconcile_script)
211
+ raise "Cannot find the bond_reconcile script: #{bond_reconcile_script}"
212
+ end
213
+
214
+ cmd = "#{Shellwords.shellescape(bond_reconcile_script)} " +
215
+ "--reference #{Shellwords.shellescape(ref_file)} " +
216
+ "--current #{Shellwords.shellescape(cur_file)} " +
217
+ "--test #{Shellwords.shellescape(@test_name)} " +
218
+ (@reconcile.nil? ? '' : "--reconcile #{@reconcile.to_s}") +
219
+ (no_save.nil? ? '' : "--no-save #{Shellwords.shellescape(no_save.to_s)}")
220
+ puts "Running: #{cmd}"
221
+ code = system(cmd)
222
+ code ? :pass : :bond_fail
223
+ end
224
+
225
+ # Save all current observations to a file. Assumes that `@observations`
226
+ # has already been JSON-serialized and outputs them all as a JSON array.
227
+ # @param fname [String] Path where the file should be saved.
228
+ def save_observations(fname)
229
+ File.open(fname, 'w') do |f|
230
+ f.print("[\n#{@observations.join(",\n")}\n]\n")
231
+ end
232
+ end
233
+
234
+ # Return the file name where observations for the current test should be
235
+ # stored. Any hierarchy (as specified by .) in the test name becomes
236
+ # a directory hierarchy, e.g. a test name of 'bond.my_tests.test_name'
237
+ # would be stored at '`{base_directory}`/bond/my_tests/test_name.json'
238
+ # If any portion of the file name (i.e. a directory or file name) is
239
+ # longer than {#MAX_FILE_NAME_LENGTH} - 5 (to account for a possible
240
+ # `.json` extension), reduce the length to 10 characters less than
241
+ # this and fill the remaining 10 characters with a hash of the full name.
242
+ def observation_file_name
243
+ name_array = @test_name.split('.').map do |name|
244
+ if name.length <= MAX_FILE_NAME_LENGTH - 5
245
+ name
246
+ else
247
+ # Using djb2 hash algorithm translated from http://www.cse.yorku.ca/~oz/hash.html
248
+ name_hash = name.chars.inject(5381) { |sum, c| ((sum << 5) + sum) + c.to_i }
249
+ # Take start of name, up to first 10 chars of the hash as base 36 (alphanumerics)
250
+ name[0, MAX_FILE_NAME_LENGTH - 15] + name_hash.to_s(36)[0, 10]
251
+ end
252
+ end
253
+ File.join(observation_directory, name_array)
254
+ end
255
+
256
+ # Return the directory where observations should be stored
257
+ # This can be specified with the `observation_directory` setting.
258
+ # If not set, it will be a 'test_observations' directory located
259
+ # in the same directory as the file containing the current test.
260
+ def observation_directory
261
+ return @observation_directory unless @observation_directory.nil?
262
+ test_file = @current_test.metadata[:file_path]
263
+ File.join(File.dirname(File.absolute_path(test_file)), 'test_observations')
264
+ end
265
+
266
+ # Formats the observation hash. Currently, this just JSON-serializes
267
+ # the hash. If any objects encountered have a `to_json` method, it will
268
+ # be called to serialize the object. Note that `to_json` takes one argument
269
+ # which is a JSON::Ext::Generator::State object; you should pass this object
270
+ # into any `to_json` calls you make within your `to_json` function.
271
+ # @param observation [Hash] Observations to be formatted.
272
+ # @return The formatted hash.
273
+ def format_observation(observation, agent = nil)
274
+ # TODO ETK actually have formatters
275
+ JSON.pretty_generate(observation, indent: ' '*4)
276
+ end
277
+
278
+ # Deep-clones an object while sorting any Hashes at any depth:
279
+ #
280
+ # - Hash: Creates a new hash containing all of the old key-value
281
+ # pairs sorted by key
282
+ # - Array: Creates a new array with the old contents *not* sorted
283
+ # - Other: Attempts to call Object#clone. If this fails (results in #TypeError)
284
+ # then the object is returned as-is (assumes that non-cloneable objects
285
+ # are immutable and thus don't need cloning)
286
+ #
287
+ # @param obj The object to be cloned.
288
+ # @return The deep-clone with hashes sorted.
289
+ def deep_clone_sort_hashes(obj)
290
+ if obj.is_a?(Hash)
291
+ {}.tap do |new|
292
+ obj.sort.each do |k, v|
293
+ new[k] = deep_clone_sort_hashes(v)
294
+ end
295
+ end
296
+ elsif obj.is_a?(Array) # Don't sort arrays, just clone
297
+ obj.map { |x| deep_clone_sort_hashes(x) }
298
+ else
299
+ begin
300
+ obj.clone
301
+ rescue TypeError # Some types, e.g. Fixnum and Symbol, can't be cloned
302
+ obj
303
+ end
304
+ end
305
+ end
306
+
307
+ end
308
+
309
+ # Represents an agent deployed by {Bond#deploy_agent}. Takes
310
+ # action on spy points depending on the options specified upon
311
+ # initialization.
312
+ # @api private
313
+ class SpyAgent
314
+
315
+ # Initialize, setting the options for this SpyAgent.
316
+ # @param opts Key-value pairs that control whether the agent is
317
+ # active and what it does. The following keys are recognized:
318
+ #
319
+ # - Keys that restrict for which invocations of bond.spy this
320
+ # agent is active. All of these conditions must be true for
321
+ # the agent to be the active one:
322
+ #
323
+ # - `key: val` - only when the observation dictionary contains
324
+ # the `key` with the given value
325
+ # - `key__contains: substr` - only when the observation dictionary
326
+ # contains the `key` with a string value that contains the given substr.
327
+ # - `key__startswith: substr` - only when the observation dictionary
328
+ # contains the `key` with a string value that starts with the given substr.
329
+ # - `key__endswith: substr` - only when the observation dictionary contains
330
+ # the `key` with a string value that ends with the given substr.
331
+ # - `filter: func` - only when the given func returns true when passed
332
+ # observation dictionary. The function should not make changes to
333
+ # the observation dictionary. Uses the observation before formatting.
334
+ #
335
+ # - Keys that control what the observer does when processed:
336
+ #
337
+ # - `do: func` - executes the given function with the observation dictionary.
338
+ # func can also be a list of functions, executed in order.
339
+ # The function should not make changes to the observation dictionary.
340
+ # Uses the observation before formatting.
341
+ #
342
+ # - Keys that control what the corresponding spy returns (by default `:agent_result_none`):
343
+ #
344
+ # - `exception: x` - the call to bond.spy throws the given exception. If `x`
345
+ # is a function it is invoked on the observation dictionary to compute
346
+ # the exception to throw. The function should not make changes to the
347
+ # observation dictionary. Uses the observation before formatting.
348
+ # - `result: x` - the call to bond.spy returns the given value. If `x` is a
349
+ # function it is invoked on the observe argument dictionary to compute
350
+ # the value to return. If the function throws an exception then the
351
+ # spied function throws an exception. The function should not make
352
+ # changes to the observation dictionary. Uses the observation before
353
+ # formatting.
354
+ #
355
+ # - Keys that control how the observation is saved. This is processed after all
356
+ # the above functions. **NOT YET AVAILABLE**
357
+ #
358
+ # - `formatter: func` - If specified, a function that is given the observation and
359
+ # can update it in place. The formatted observation is what gets serialized and saved.
360
+ #
361
+ def initialize(**opts)
362
+ # TODO ETK needs formatters
363
+ @result_spec = :agent_result_none
364
+ @exception_spec = nil
365
+ @doers = []
366
+ @filters = []
367
+
368
+ opts.each do |k, v|
369
+ case k.to_s # Convert to string in case it was passed as a symbol
370
+ when 'result'
371
+ @result_spec = v
372
+ when 'exception'
373
+ @exception_spec = v
374
+ when 'do'
375
+ @doers = [*v]
376
+ else # Must be a filter
377
+ @filters <<= SpyAgentFilter.new(k.to_s, v)
378
+ end
379
+ end
380
+ end
381
+
382
+ # Checks if this agent should process this observation
383
+ # @return true iff this agent should process this observation
384
+ def process?(observation)
385
+ @filters.empty? || @filters.all? do |filter|
386
+ filter.accept?(observation)
387
+ end
388
+ end
389
+
390
+ # Carries out all of the actions specified by the `do` option
391
+ def do(observation)
392
+ @doers.each { |doer| doer.call(observation) }
393
+ end
394
+
395
+ # Gets the result that this agent should return, dependent on the
396
+ # `result` and `exception` options. If neither is present,
397
+ # returns `:agent_result_none`.
398
+ def result(observation)
399
+ unless @exception_spec.nil?
400
+ raise @exception_spec.respond_to?(:call) ? @exception_spec.call(observation) : @exception_spec
401
+ end
402
+
403
+ if !@result_spec.nil? && @result_spec.respond_to?(:call)
404
+ @result_spec.call(observation)
405
+ else
406
+ @result_spec
407
+ end
408
+ end
409
+ end
410
+
411
+ # Filters used to determine whether or not a {SpyAgent} should
412
+ # be applied to a given observation.
413
+ # @api private
414
+ class SpyAgentFilter
415
+
416
+ # Initialize this filter.
417
+ # @see Bond#deploy_agent
418
+ # @param filter_key [#to_s] The key for the filter.
419
+ # @param filter_value The value for the filter.
420
+ def initialize(filter_key, filter_value)
421
+ @field_name = nil
422
+ @filter_func = nil
423
+
424
+ filter_key = filter_key.to_s
425
+ if filter_key == 'filter'
426
+ raise 'When using filter, passed value must be callable' unless filter_value.respond_to?(:call)
427
+ @filter_func = filter_value
428
+ return
429
+ end
430
+
431
+ key_parts = filter_key.split('__')
432
+ if key_parts.length == 1
433
+ @field_name = key_parts[0]
434
+ @filter_func = lambda { |val| val == filter_value }
435
+ elsif key_parts.length == 2
436
+ @field_name = key_parts[0]
437
+ case key_parts[1]
438
+ when 'exact','eq'
439
+ @filter_func = lambda { |val| val == filter_value }
440
+ when 'startswith'
441
+ @filter_func = lambda { |val| val.start_with?(filter_value) }
442
+ when 'endswith'
443
+ @filter_func = lambda { |val| val.end_with?(filter_value) }
444
+ when 'contains'
445
+ @filter_func = lambda { |val| val.include?(filter_value) }
446
+ else
447
+ raise "Unknown operator: #{key_parts[1]}"
448
+ end
449
+ else
450
+ raise "Invalid key passed in: #{filter_key}"
451
+ end
452
+ end
453
+
454
+ # Return true iff the provided observation meets this filter.
455
+ def accept?(observation)
456
+ if @field_name.nil?
457
+ @filter_func.call(observation)
458
+ elsif observation.has_key?(@field_name)
459
+ @filter_func.call(observation[@field_name])
460
+ elsif observation.has_key?(@field_name.to_sym)
461
+ @filter_func.call(observation[@field_name.to_sym])
462
+ else
463
+ false
464
+ end
465
+ end
466
+
467
+ end
468
+
469
+ require_relative 'bond/targetable'