brainguy 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.yardopts +4 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.erb +345 -0
  7. data/README.markdown +579 -0
  8. data/Rakefile +21 -0
  9. data/brainguy.gemspec +28 -0
  10. data/examples/include_manifestly_observable.rb +22 -0
  11. data/examples/include_observable.rb +18 -0
  12. data/examples/include_observer.rb +36 -0
  13. data/examples/manual_observable.rb +22 -0
  14. data/examples/open_observer.rb +31 -0
  15. data/examples/proc_observer.rb +10 -0
  16. data/examples/scoped_subscription.rb +39 -0
  17. data/examples/synopsis.rb +56 -0
  18. data/lib/brainguy.rb +34 -0
  19. data/lib/brainguy/basic_notifier.rb +19 -0
  20. data/lib/brainguy/emitter.rb +110 -0
  21. data/lib/brainguy/error_collecting_notifier.rb +26 -0
  22. data/lib/brainguy/error_handling_notifier.rb +63 -0
  23. data/lib/brainguy/event.rb +13 -0
  24. data/lib/brainguy/fluent_emitter.rb +30 -0
  25. data/lib/brainguy/full_subscription.rb +8 -0
  26. data/lib/brainguy/idempotent_emitter.rb +40 -0
  27. data/lib/brainguy/manifest_emitter.rb +78 -0
  28. data/lib/brainguy/manifestly_observable.rb +62 -0
  29. data/lib/brainguy/observable.rb +33 -0
  30. data/lib/brainguy/observer.rb +71 -0
  31. data/lib/brainguy/open_observer.rb +65 -0
  32. data/lib/brainguy/single_event_subscription.rb +31 -0
  33. data/lib/brainguy/subscription.rb +59 -0
  34. data/lib/brainguy/subscription_scope.rb +62 -0
  35. data/lib/brainguy/version.rb +4 -0
  36. data/scripts/benchmark_listener_dispatch.rb +222 -0
  37. data/spec/brainguy/emitter_spec.rb +25 -0
  38. data/spec/brainguy/error_collecting_notifier_spec.rb +19 -0
  39. data/spec/brainguy/error_handling_notifier_spec.rb +63 -0
  40. data/spec/brainguy/manifest_emitter_spec.rb +68 -0
  41. data/spec/brainguy/manifestly_observable_spec.rb +43 -0
  42. data/spec/brainguy/observable_spec.rb +9 -0
  43. data/spec/brainguy/observer_spec.rb +72 -0
  44. data/spec/brainguy/open_observer_spec.rb +57 -0
  45. data/spec/brainguy/single_event_subscription_spec.rb +16 -0
  46. data/spec/brainguy/subscription_scope_spec.rb +72 -0
  47. data/spec/brainguy/subscription_spec.rb +46 -0
  48. data/spec/features/basics_spec.rb +153 -0
  49. data/spec/features/idempotent_events_spec.rb +69 -0
  50. data/spec/features/method_scoped_events_spec.rb +90 -0
  51. data/spec/support/shared_examples_for_eventful_modules.rb +36 -0
  52. metadata +196 -0
@@ -0,0 +1,26 @@
1
+ require "brainguy/error_handling_notifier"
2
+
3
+ module Brainguy
4
+ # A notifier wrapper that captures exceptions and collects them into a Hash.
5
+ class ErrorCollectingNotifier < ErrorHandlingNotifier
6
+ # (see ErrorHandlingNotifier#initialize)
7
+ def initialize(notifier)
8
+ super(notifier, method(:add_error))
9
+ @errors = {}
10
+ end
11
+
12
+ # Add another error to the list
13
+ # @api private
14
+ def add_error(subscription, error)
15
+ @errors[subscription] = error
16
+ end
17
+
18
+ # Return list of errors captured while notifying subscriptions. One entry
19
+ # for every subscription that raised an error.
20
+ #
21
+ # @return [Hash{Subscription => Exception}]
22
+ def result
23
+ @errors
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,63 @@
1
+ require "brainguy/basic_notifier"
2
+
3
+ module Brainguy
4
+ # It is possible that errors may be raised when notifying listeners. This
5
+ # notifier wrapper class provides some leeway in how to handle those
6
+ # errors. It does this by capturing errors and applying a policy to them.
7
+ #
8
+ # Includes a selection of common error policies.
9
+ class ErrorHandlingNotifier < DelegateClass(BasicNotifier)
10
+ # The suppression strategy. Throws errors away.
11
+ SUPPRESS_STRATEGY = ->(_subscription, _error) do
12
+ # NOOP
13
+ end
14
+
15
+ # The warning strategy. Turns errors into warnings.
16
+ WARN_STRATEGY = ->(_subscription, error) do
17
+ warn "#{error.class}: #{error.message}"
18
+ end
19
+
20
+ # The raise strategy. Re-raises errors as if they had never been captured.
21
+ RAISE_STRATEGY = ->(_subscription, error) do
22
+ raise error
23
+ end
24
+
25
+ # @param notifier [#notify] the notifier to wrap
26
+ # @param error_handler [:call] a callable that determined error policy
27
+ # There are some symbolic shortcuts available here:
28
+ # - `:suppress`: Suppress errors completely.
29
+ # - `:warn`: Convert errors to warnings.
30
+ # - `:raise`: Re-raise errors.
31
+ def initialize(notifier, error_handler = RAISE_STRATEGY)
32
+ super(notifier)
33
+ @error_handler = resolve_error_handler(error_handler)
34
+ end
35
+
36
+ # Notify a `subscription` of an event, and apply the error policy to any
37
+ # exception that is raised.
38
+ #
39
+ # @return [@Object] whatever the underlying notifier returned
40
+ def notify(subscription, *)
41
+ super
42
+ rescue => error
43
+ @error_handler.call(subscription, error)
44
+ end
45
+
46
+ # @return [nil]
47
+ def result
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ def resolve_error_handler(handler)
54
+ case handler
55
+ when :suppress then SUPPRESS_STRATEGY
56
+ when :warn then WARN_STRATEGY
57
+ when :raise then RAISE_STRATEGY
58
+ when Symbol then fail ArgumentError, "Unknown mnemonic: #{handler}"
59
+ else handler
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,13 @@
1
+ module Brainguy
2
+ # An event. Bundles up a symbolic `name`, an originating object (`source`).
3
+ # and a list of event-defined `args`.
4
+ Event = Struct.new(:name, :source, :args) do
5
+ # @param name [Symbol] the event name
6
+ # @param source [Object] the originating object
7
+ # @param args [Array] a list of event-specific arguments
8
+ def initialize(*)
9
+ super
10
+ self.args ||= []
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ require "brainguy/emitter"
2
+ require "delegate"
3
+
4
+ module Brainguy
5
+ # A wrapper for a {Emitter} that enables a "fluent API" by
6
+ # returning `self` from each method.
7
+ #
8
+ # @example Enables code like this:
9
+ #
10
+ # kitten.on(:purr) do
11
+ # # handle purr...
12
+ # end.on(:mew) do
13
+ # # handle mew...
14
+ # end
15
+ class FluentEmitter < DelegateClass(Emitter)
16
+ # (see Emitter#on)
17
+ # @return `self`
18
+ def on(*)
19
+ super
20
+ self
21
+ end
22
+
23
+ # (see Emitter#attach)
24
+ # @return `self`
25
+ def attach(*)
26
+ super
27
+ self
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,8 @@
1
+ require "brainguy/subscription"
2
+
3
+ module Brainguy
4
+ # A {Subscription} which transmits all events to the listener.
5
+ class FullSubscription < Subscription
6
+
7
+ end
8
+ end
@@ -0,0 +1,40 @@
1
+ require "brainguy/emitter"
2
+
3
+ module Brainguy
4
+ # A type of {Emitter} that records and "plays back" events to new
5
+ # listeners. That way a listener will never miss an event, even if it
6
+ # subscribes late.
7
+ #
8
+ # This class is probably best used in short-lived scopes, since the log of
9
+ # events will continually grow.
10
+ class IdempotentEmitter < Emitter
11
+ # (see Emitter#initialize)
12
+ def initialize(*)
13
+ super
14
+ @event_log = []
15
+ end
16
+
17
+ # Emit an event and record it in the log.
18
+ # @event_name (see Emitter#emit)
19
+ # @extra_args (see Emitter#emit)
20
+ # @return (see Emitter#emit)
21
+ def emit(event_name, *extra_args)
22
+ @event_log.push(Event.new(event_name, @event_source, extra_args))
23
+ super
24
+ end
25
+
26
+ # Add a new subscription, and immediately play back any missed events to
27
+ # it.
28
+ #
29
+ # @param subscription (see Emitter#<<)
30
+ # @return (see Emitter#<<)
31
+ def <<(subscription)
32
+ super
33
+ @event_log.each do |event|
34
+ subscription.handle(event)
35
+ end
36
+ end
37
+
38
+ alias_method :add, :<<
39
+ end
40
+ end
@@ -0,0 +1,78 @@
1
+ require "brainguy/emitter"
2
+ require "delegate"
3
+
4
+ module Brainguy
5
+ # Raised for an event that is not listed in the manifest.
6
+ class UnknownEvent < StandardError
7
+ end
8
+
9
+ # A {Emitter} wrapper which "locks down" a subscription set to a
10
+ # known list of event names. Useful for preventing typos in event names.
11
+ class ManifestEmitter < DelegateClass(Emitter)
12
+
13
+ # A policy which outputs a warning on unrecognized event names
14
+ WARN_POLICY = Kernel.method(:warn)
15
+
16
+ # A policy which raises an exception on unrecognized evend names.
17
+ RAISE_ERROR_POLICY = ->(message) do
18
+ fail UnknownEvent, message, caller.drop_while{|l| l.include?(__FILE__)}
19
+ end
20
+
21
+ # The list of known event names
22
+ # @return [Array<Symbol>]
23
+ attr_reader :known_types
24
+
25
+ # @param subscription_set [Emitter] the set to be wrapped
26
+ # @param options [Hash] a hash of options
27
+ # @option options [:call, Symbol] :policy the policy for what to do on
28
+ # unknown event names. A callable or a mnemonic symbol.
29
+ # The following mnemonics are supported:
30
+ #
31
+ # - `:warn`: Output a warning
32
+ # - `:raise_error`: Raise an exception
33
+ def initialize(subscription_set, options = {})
34
+ super(subscription_set)
35
+ @known_types = []
36
+ policy = options.fetch(:policy) { :warn }
37
+ @policy = resolve_policy(policy)
38
+ end
39
+
40
+
41
+ def unknown_event_policy=(new_policy)
42
+ @policy = resolve_policy(new_policy)
43
+ end
44
+
45
+ # (see Emitter#on)
46
+ def on(event_name, &block)
47
+ check_event_name(event_name, __callee__)
48
+ super
49
+ end
50
+
51
+ # (see Emitter#emit)
52
+ def emit(event_name, *)
53
+ check_event_name(event_name, __callee__)
54
+ super
55
+ end
56
+
57
+ private
58
+
59
+ def check_event_name(event_name, method_name)
60
+ unless @known_types.include?(event_name)
61
+ message =
62
+ "##{method_name} received for unknown event type '#{event_name}'"
63
+ @policy.call(message)
64
+ end
65
+ end
66
+
67
+ def resolve_policy(policy)
68
+ case policy
69
+ when :warn
70
+ WARN_POLICY
71
+ when :raise_error
72
+ RAISE_ERROR_POLICY
73
+ else
74
+ fail ArgumentError, "Invalid policy: #{policy}"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,62 @@
1
+ require "brainguy/observable"
2
+ require "brainguy/manifest_emitter"
3
+
4
+ module Brainguy
5
+ # A custom {Module} subclass which acts like {Observable}, except with an
6
+ # event type manifest. This is a way to define an observable cladd with a
7
+ # known list of possible event types.
8
+ #
9
+ # @example
10
+ # class MyApiRequest
11
+ # include ManifestlyObservable.new(:success, :unauthorized, :error)
12
+ # # ...
13
+ # end
14
+ #
15
+ class ManifestlyObservable < Module
16
+
17
+ # Generate a new mixin module with a custom list of known event types.
18
+ def initialize(*known_events)
19
+ # Look out, this gets a bit gnarly...
20
+ @known_events = known_events
21
+ # Define the module body
22
+ super() do
23
+ # First off, let's make sure we have basic Observable functionality
24
+ include Observable
25
+
26
+ # Now, we override #events to wrap it in some extra goodness
27
+ define_method :events do
28
+
29
+ # Let's see what the current subscription set object is
30
+ subscription_set = super()
31
+
32
+ # If there is already another ManifestlyObservable included further
33
+ # up the chain...
34
+ if subscription_set.is_a?(ManifestEmitter)
35
+ # just add our event types to its subscription set
36
+ subscription_set.known_types.concat(known_events)
37
+ # But if this is the first ManifestlyObservable included...
38
+ else
39
+ # Wrap the subscription set in a ManifestEmitter
40
+ @brainguy_events = ManifestEmitter.new(subscription_set)
41
+ # Set up the known event types
42
+ @brainguy_events.known_types.concat(known_events)
43
+ end
44
+
45
+ # No need to do all this every time. Once we've set everything up,
46
+ # redefine the method on a per-instance level to be a simple getter.
47
+ define_singleton_method :events do
48
+ @brainguy_events
49
+ end
50
+ # Don't forget to return a value the first time around
51
+ @brainguy_events
52
+ end
53
+ end
54
+ end
55
+
56
+ # Return a meaningful description of the generated module.
57
+ # @return [String]
58
+ def to_s
59
+ "ManifestlyObservable(#{@known_events.map(&:inspect).join(', ')})"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,33 @@
1
+ require "forwardable"
2
+ require "brainguy/emitter"
3
+
4
+ module Brainguy
5
+ # A convenience module for making client classes observable.
6
+ module Observable
7
+ extend Forwardable
8
+
9
+ # @return [Emitter] the {Emitter} managing all
10
+ # subscriptions to this object's events.
11
+ def events
12
+ @brainguy_subscriptions ||= Emitter.new(self)
13
+ end
14
+
15
+ # Create a temporary scope for transient subscriptions. Useful for
16
+ # making a single method listenable. See {file:README.md} for usage
17
+ # examples.
18
+ #
19
+ # @param listener_block (see Brainguy.with_subscription_scope)
20
+ def with_subscription_scope(listener_block, &block)
21
+ Brainguy.with_subscription_scope(self, listener_block, events, &block)
22
+ end
23
+
24
+ # @!method on(name_or_handlers, &block)
25
+ # (see {Emitter#on})
26
+ def_delegator :events, :on
27
+
28
+ # @!method emit
29
+ # (see {Emitter#emit})
30
+ def_delegator :events, :emit
31
+ private :emit
32
+ end
33
+ end
@@ -0,0 +1,71 @@
1
+ require "brainguy/event"
2
+
3
+ module Brainguy
4
+
5
+ # A mixin for formal "listener" classes. Events received by the including
6
+ # class will be dispatched to `#on_<EVENT_NAME>` methods, if they exist. If
7
+ # no event-specific handler method is found, it will be sent to
8
+ # {#event_handler_missing}.
9
+ module Observer
10
+ module ClassMethods
11
+ HANDLER_METHOD_PATTERN = /\Aon_(.+)\z/
12
+
13
+ def method_added(method_name)
14
+ if method_name.to_s =~ HANDLER_METHOD_PATTERN
15
+ regenerate_dispatch_method
16
+ end
17
+ super
18
+ end
19
+
20
+ # [Re]generate a #call method containing a fast dispatch table from
21
+ # event names to handler method names. The code generation strategy here
22
+ # has been benchmarked as equivalent to hand-written dispatch code
23
+ # on Ruby 2.2. See {file:scripts/benchmark_listener_dispatch.rb} for
24
+ # the relevant benchmark.
25
+ def regenerate_dispatch_method
26
+ dispatch_table = instance_methods
27
+ .map(&:to_s)
28
+ .grep(HANDLER_METHOD_PATTERN) { |method_name|
29
+ event_name = $1
30
+ "when :#{event_name} then #{method_name}(event)"
31
+ }.join("\n")
32
+ code = %{
33
+ def call(event)
34
+ case event.name
35
+ #{dispatch_table}
36
+ # Note: following case is mainly here to keep the parser happy
37
+ # when there are no other cases (i.e. no handler methods defined
38
+ # yet).
39
+ when nil then fail ArgumentError, "Event cannot be nil"
40
+ else event_handler_missing(event)
41
+ end
42
+ end
43
+ }
44
+ class_eval code
45
+ rescue SyntaxError => error
46
+ # SyntaxErrors suck when you can't look at the syntax
47
+ error.message << "\n\nGenerated code:\n\n#{code}"
48
+ raise error
49
+ end
50
+ end
51
+
52
+ def self.included(klass)
53
+ klass.extend(ClassMethods)
54
+ klass.regenerate_dispatch_method
55
+ end
56
+
57
+
58
+ # Empty placeholder in case the client class doesn't supply a custom
59
+ # `#event_handler_missing`
60
+ def event_handler_missing(event)
61
+ # just a placeholder
62
+ end
63
+
64
+ # @!method call(event)
65
+ # Dispatch events to handler methods.
66
+ #
67
+ # This method is automatically [re]generated by
68
+ # {ClassMethods#regenerate_dispatch_method} every time a new
69
+ # `#on_*` method is defined.
70
+ end
71
+ end
@@ -0,0 +1,65 @@
1
+ module Brainguy
2
+ # A quick and dirty way to set up a reusable listener object. Like an
3
+ # OpenObject, only for event listening!
4
+ class OpenObserver
5
+ # @param handlers [Hash{Symbol => [:call]}] a Hash of event names to
6
+ # callable handlers
7
+ # @yield [self] if a block is given
8
+ # @example Initializing and then adding handlers dynamically
9
+ # ol = OpenObserver.new
10
+ # ol.on_foo do
11
+ # # ...
12
+ # end
13
+ # ol.on_bar do
14
+ # # ...
15
+ # end
16
+ # @example Initializing with a block
17
+ # listener = OpenObserver.new do |ol|
18
+ # ol.on_foo do
19
+ # # ...
20
+ # end
21
+ # ol.on_bar do
22
+ # # ...
23
+ # end
24
+ # end
25
+ # @example Initializing from a hash
26
+ # listener = OpenObserver.new(foo: ->{...}, bar: ->{...})
27
+ def initialize(handlers = {})
28
+ @handlers = handlers
29
+ yield self if block_given?
30
+ end
31
+
32
+ # Dispatch the event to the appropriate handler, if one has been set.
33
+ # Events without handlers are ignored.
34
+ # @param event [Event] the event to be handled
35
+ def call(event)
36
+ if (handler = @handlers[event.name])
37
+ handler.call(event)
38
+ end
39
+ end
40
+
41
+ # Enable setting up event handlers dynamically using `on_*` message sends.
42
+ # @example
43
+ # ol = OpenObserver.new
44
+ # ol.on_foo do
45
+ # # ...
46
+ # end
47
+ # ol.on_bar do
48
+ # # ...
49
+ # end
50
+ def method_missing(method_name, *_args, &block)
51
+ if method_name.to_s =~ /\Aon_(.+)/
52
+ @handlers[$1.to_sym] = block
53
+ self
54
+ else
55
+ super
56
+ end
57
+ end
58
+
59
+ # true if the method starts with `on_`
60
+ # @return [Boolean]
61
+ def respond_to_missing?(method_name, _include_private)
62
+ method_name.to_s =~ /\Aon_./
63
+ end
64
+ end
65
+ end