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