brainguy 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.yardopts +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.erb +345 -0
- data/README.markdown +579 -0
- data/Rakefile +21 -0
- data/brainguy.gemspec +28 -0
- data/examples/include_manifestly_observable.rb +22 -0
- data/examples/include_observable.rb +18 -0
- data/examples/include_observer.rb +36 -0
- data/examples/manual_observable.rb +22 -0
- data/examples/open_observer.rb +31 -0
- data/examples/proc_observer.rb +10 -0
- data/examples/scoped_subscription.rb +39 -0
- data/examples/synopsis.rb +56 -0
- data/lib/brainguy.rb +34 -0
- data/lib/brainguy/basic_notifier.rb +19 -0
- data/lib/brainguy/emitter.rb +110 -0
- data/lib/brainguy/error_collecting_notifier.rb +26 -0
- data/lib/brainguy/error_handling_notifier.rb +63 -0
- data/lib/brainguy/event.rb +13 -0
- data/lib/brainguy/fluent_emitter.rb +30 -0
- data/lib/brainguy/full_subscription.rb +8 -0
- data/lib/brainguy/idempotent_emitter.rb +40 -0
- data/lib/brainguy/manifest_emitter.rb +78 -0
- data/lib/brainguy/manifestly_observable.rb +62 -0
- data/lib/brainguy/observable.rb +33 -0
- data/lib/brainguy/observer.rb +71 -0
- data/lib/brainguy/open_observer.rb +65 -0
- data/lib/brainguy/single_event_subscription.rb +31 -0
- data/lib/brainguy/subscription.rb +59 -0
- data/lib/brainguy/subscription_scope.rb +62 -0
- data/lib/brainguy/version.rb +4 -0
- data/scripts/benchmark_listener_dispatch.rb +222 -0
- data/spec/brainguy/emitter_spec.rb +25 -0
- data/spec/brainguy/error_collecting_notifier_spec.rb +19 -0
- data/spec/brainguy/error_handling_notifier_spec.rb +63 -0
- data/spec/brainguy/manifest_emitter_spec.rb +68 -0
- data/spec/brainguy/manifestly_observable_spec.rb +43 -0
- data/spec/brainguy/observable_spec.rb +9 -0
- data/spec/brainguy/observer_spec.rb +72 -0
- data/spec/brainguy/open_observer_spec.rb +57 -0
- data/spec/brainguy/single_event_subscription_spec.rb +16 -0
- data/spec/brainguy/subscription_scope_spec.rb +72 -0
- data/spec/brainguy/subscription_spec.rb +46 -0
- data/spec/features/basics_spec.rb +153 -0
- data/spec/features/idempotent_events_spec.rb +69 -0
- data/spec/features/method_scoped_events_spec.rb +90 -0
- data/spec/support/shared_examples_for_eventful_modules.rb +36 -0
- 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,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
|