brainguy 0.0.1

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 (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