notch8-alter-ego 1.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.
data/History.txt ADDED
@@ -0,0 +1,4 @@
1
+ == 1.0.0 2008-11-28
2
+
3
+ * 1 major enhancement:
4
+ * Initial release
data/Manifest.txt ADDED
@@ -0,0 +1,18 @@
1
+ History.txt
2
+ Manifest.txt
3
+ PostInstall.txt
4
+ README.rdoc
5
+ State_Design_Pattern_UML_Class_Diagram.png
6
+ Rakefile
7
+ TODO
8
+ lib/alter_ego.rb
9
+ lib/hash.rb
10
+ lib/hash/keys.rb
11
+ lib/hookr.rb
12
+ script/console
13
+ script/destroy
14
+ script/generate
15
+ spec/spec.opts
16
+ spec/spec_helper.rb
17
+ spec/alter_ego_spec.rb
18
+ tasks/rspec.rake
data/PostInstall.txt ADDED
@@ -0,0 +1,7 @@
1
+
2
+ For more information on states, see http://alter-ego.rubyforge.org
3
+
4
+ NOTE: Change this information in PostInstall.txt
5
+ You can also delete it if you don't want it.
6
+
7
+
data/README.rdoc ADDED
@@ -0,0 +1,149 @@
1
+ = AlterEgo
2
+
3
+ * http://alter-ego.rubyforge.org
4
+
5
+ == DESCRIPTION:
6
+
7
+ AlterEgo is a Ruby implementation of the State pattern as described by the Gang
8
+ of Four. It differs from other Ruby state machine libraries in that it focuses
9
+ on providing polymorphic behavior based on object state. In effect, it makes it
10
+ easy to give an object different personalities depending on the state it is in.
11
+
12
+ == SYNOPSIS:
13
+
14
+ class TrafficLight
15
+ include AlterEgo
16
+
17
+ state :proceed, :default => true do
18
+ handle :color do
19
+ "green"
20
+ end
21
+ transition :to => :caution, :on => :cycle!
22
+ end
23
+
24
+ state :caution do
25
+ handle :color do
26
+ "yellow"
27
+ end
28
+ transition :to => :stop, :on => :cycle!
29
+ end
30
+
31
+ state :stop do
32
+ handle :color do
33
+ "red"
34
+ end
35
+ transition :to => :proceed, :on => :cycle!
36
+ end
37
+ end
38
+
39
+ light = TrafficLight.new
40
+ light.color # => "green"
41
+ light.cycle!
42
+ light.color # => "yellow"
43
+ light.cycle!
44
+ light.color # => "red"
45
+ light.cycle!
46
+ light.color # => "green"
47
+
48
+
49
+ == FEATURES:
50
+
51
+ * Implemented as a module which can be included in any Ruby class.
52
+ * Fully tested with literate RSpec
53
+ * Guard clauses may be defined for each transition.
54
+ * Enter/exit actions may be defined for each state.
55
+ * For more advanced scenarios, arbitrary "request filters" may be
56
+ defined with full control over which requests are filtered.
57
+ * Uses dynamic module generation and delegation instead of method
58
+ rewriting.
59
+ * Pervasive contract-checking catches mistakes in library usage
60
+ early.
61
+ * Storing and reading current state is completely customizable,
62
+ making it easier to add AlterEgo to legacy classes.
63
+
64
+ == DETAILS:
65
+
66
+ AlterEgo differs from other Finite State Machine implementations in
67
+ Ruby in that where other libraries focus on describing a set of
68
+ valid state transitions, AlterEgo focuses on varying _behavior_ based
69
+ on state. In other words, it provides state-based polymorphism.
70
+
71
+ AlterEgo draws heavily on the State Pattern as published in the book
72
+ Design Patterns[1]. A summary of the pattern can be
73
+ found on Wikipedia[http://en.wikipedia.org/wiki/State_pattern].
74
+ Because AlterEgo uses the terminology set forth in the State Pattern,
75
+ it is useful to have some familiarity with the pattern in order to
76
+ understand the library.
77
+
78
+ link://State_Design_Pattern_UML_Class_Diagram.png
79
+
80
+ In the State Pattern, states of an object are represented as
81
+ discrete objects. At any given time an object with state-based
82
+ behavior has-a state object. The object with state-based behavior
83
+ delegates certain method calls to its current state. In this way,
84
+ the implementation of those methods can vary with the state of the
85
+ object. Or in certain states some methods may not be supported at
86
+ all.
87
+
88
+ The AlterEgo library provides both an object model for manually
89
+ setting up explicit state classes, and a concise DSL built on top
90
+ of that model which simplifies building classes with state-based
91
+ behavior.
92
+
93
+ This file only scratches the surface of AlterEgo
94
+ functionality. For complete tutorial documentation, see the file
95
+ spec/alter_ego_spec.rb. It contains the annotated specification,
96
+ written in the style of a step-by-step tutorial.
97
+
98
+ === Terminology:
99
+
100
+ [Context] The *context* is the class which should have state-based
101
+ behavior. In the example above, the +TrafficLight+ class
102
+ is the context.
103
+ [State] Each *state* the *context* might exist in is represented by a
104
+ class, In the example given above, the available states
105
+ are +caution+, +stop+, and +proceed+.
106
+ [Request] A *request* refers to a message (method) sent to the
107
+ *context*. In the example given above, the supported
108
+ requests are +color+ and +cycle+.
109
+ [Handler] A *handler* is a method on a *state* which implements a
110
+ *request* for that state. For instance, in the example
111
+ above, when the +TrafficLight+ is in the +caution+ state,
112
+ the handler for the request +color+ returns "yellow".
113
+
114
+ === Footnotes:
115
+
116
+ 1. Gamma, Erich; Richard Helm, Ralph Johnson, John M. Vlissides (1995). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 395. ISBN 0201633612.
117
+
118
+ == REQUIREMENTS:
119
+
120
+ * ActiveSupport
121
+
122
+ == INSTALL:
123
+
124
+ * sudo gem install alter-ego
125
+
126
+ == LICENSE:
127
+
128
+ (The MIT License)
129
+
130
+ Copyright (c) 2008 Avdi Grimm
131
+
132
+ Permission is hereby granted, free of charge, to any person obtaining
133
+ a copy of this software and associated documentation files (the
134
+ 'Software'), to deal in the Software without restriction, including
135
+ without limitation the rights to use, copy, modify, merge, publish,
136
+ distribute, sublicense, and/or sell copies of the Software, and to
137
+ permit persons to whom the Software is furnished to do so, subject to
138
+ the following conditions:
139
+
140
+ The above copyright notice and this permission notice shall be
141
+ included in all copies or substantial portions of the Software.
142
+
143
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
144
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
145
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
146
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
147
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
148
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
149
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,33 @@
1
+ %w[rubygems rake rake/clean fileutils newgem rubigen].each { |f| require f }
2
+ require File.dirname(__FILE__) + '/lib/alter_ego'
3
+
4
+ # Generate all the Rake tasks
5
+ # Run 'rake -T' to see list of generated tasks (from gem root directory)
6
+ $hoe = Hoe.new('alter-ego', AlterEgo::VERSION) do |p|
7
+ p.developer('Avdi Grimm', 'avdi@avdi.org')
8
+ p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n")
9
+ p.rubyforge_name = p.name # TODO this is default value
10
+ p.extra_deps = [
11
+ ['fail-fast', '>= 1.0.0']
12
+ ]
13
+ p.extra_dev_deps = [
14
+ ['newgem', ">= #{::Newgem::VERSION}"]
15
+ ]
16
+
17
+ p.clean_globs |= %w[**/.DS_Store tmp *.log]
18
+ path = (p.rubyforge_name == p.name) ? p.rubyforge_name : "\#{p.rubyforge_name}/\#{p.name}"
19
+ p.remote_rdoc_dir = File.join(path.gsub(/^#{p.rubyforge_name}\/?/,''), 'rdoc')
20
+ p.rsync_args = '-av --delete --ignore-errors'
21
+ end
22
+
23
+ require 'newgem/tasks' # load /tasks/*.rake
24
+ Dir['tasks/**/*.rake'].each { |t| load t }
25
+
26
+ # TODO - want other tests/tasks run by default? Add them to the list
27
+ # task :default => [:spec, :features]
28
+
29
+ task :docs do |task|
30
+ cp "State_Design_Pattern_UML_Class_Diagram.png", "doc"
31
+ end
32
+
33
+ task :default => :spec
data/TODO ADDED
@@ -0,0 +1,12 @@
1
+ # -*- mode: org -*-
2
+ * DONE Factor assertions out into separate library
3
+ CLOSED: [2008-11-29 Sat 01:29]
4
+ * TODO Remove ActiveSupport dependency
5
+ * TODO Nested state support with history
6
+ * TODO Verify full methods/respond_to? integration
7
+ * TODO Composite state support
8
+ E.g. a Traffic Light could have both green/yellow/red state and "in
9
+ service"/out of service" state at the same time
10
+ * TODO More natural/Rubyish DSL
11
+ It would be good to have plain-old "def" work as expected inside of "state"
12
+ blocks instead of requiring "handle".
data/lib/alter_ego.rb ADDED
@@ -0,0 +1,388 @@
1
+ $:.unshift(File.dirname(__FILE__)) unless
2
+ $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
3
+
4
+ require 'forwardable'
5
+ require 'singleton'
6
+ require 'rubygems'
7
+ require File.join(File.dirname(__FILE__), 'hash')
8
+ #require 'activesupport'
9
+ require 'fail_fast'
10
+ require 'hookr'
11
+
12
+ module AlterEgo
13
+ VERSION = '1.0.0'
14
+
15
+ include FailFast::Assertions
16
+
17
+ class StateError < RuntimeError
18
+ end
19
+ class InvalidDefinitionError < StateError
20
+ end
21
+ class InvalidTransitionError < StateError
22
+ end
23
+ class InvalidRequestError < StateError
24
+ end
25
+ class WrongStateError < StateError
26
+ end
27
+
28
+ RequestFilter = Struct.new("RequestFilter",
29
+ :state,
30
+ :request,
31
+ :new_state,
32
+ :action)
33
+ class RequestFilter
34
+ def ===(other)
35
+ result = (matches?(self.state, other.state) and
36
+ matches?(self.request, other.request) and
37
+ matches?(self.new_state, other.new_state))
38
+ result
39
+ end
40
+
41
+ def matches?(lhs, rhs)
42
+ if rhs.respond_to?(:include?)
43
+ rhs.include?(lhs)
44
+ else
45
+ rhs == lhs
46
+ end
47
+ end
48
+ end
49
+
50
+ class AnyMatcher
51
+ include Singleton
52
+
53
+ def ===(other)
54
+ self == other
55
+ end
56
+
57
+ def ==(other)
58
+ true
59
+ end
60
+ end
61
+
62
+ class NotNilMatcher
63
+ include Singleton
64
+
65
+ def ===(other)
66
+ self == other
67
+ end
68
+
69
+ def ==(other)
70
+ not other.nil?
71
+ end
72
+ end
73
+
74
+ # A customization of Hookr::Hook to deal with the fact that State internal
75
+ # callbacks need to be executed in the context of the state's context, not the
76
+ # state object itself.
77
+ class StateHook < Hookr::Hook
78
+ class StateContextCallback < Hookr::InternalCallback
79
+ def call(event)
80
+ context = event.arguments.first
81
+ context.instance_eval(&block)
82
+ end
83
+ end
84
+
85
+ # Add an internal callback that executes in the context of the state
86
+ # context, instead of the state itself
87
+ def add_internal_callback(handle=nil, &block)
88
+ add_block_callback(StateContextCallback, handle, &block)
89
+ end
90
+ end
91
+
92
+ class State
93
+ include FailFast::Assertions
94
+ extend FailFast::Assertions
95
+ include Hookr::Hooks
96
+
97
+ def self.transition(options, &trans_action)
98
+ options.assert_valid_keys(:to, :on, :if)
99
+ assert_keys(options, :to)
100
+ guard = options[:if]
101
+ to_state = options[:to]
102
+ request = options[:on]
103
+ if request
104
+ handle(request) do
105
+ transition_to(to_state, request)
106
+ end
107
+ end
108
+ valid_transitions << to_state unless valid_transitions.include?(to_state)
109
+ if guard
110
+ method = guard.kind_of?(Symbol) ? guard : nil
111
+ block = guard.kind_of?(Proc) ? guard : nil
112
+ predicate = AlterEgo.proc_from_symbol_or_block(method, &block)
113
+ guard_proc = proc do
114
+ result = instance_eval(&predicate)
115
+ throw :cancel unless result
116
+ end
117
+ add_request_filter(request, to_state, guard_proc)
118
+ end
119
+ if trans_action
120
+ add_request_filter(request, to_state, trans_action)
121
+ end
122
+ end
123
+
124
+ def self.identifier
125
+ self
126
+ end
127
+
128
+ def self.valid_transitions
129
+ (@valid_transitions ||= [])
130
+ end
131
+
132
+ def self.handled_requests
133
+ public_instance_methods(false)
134
+ end
135
+
136
+ def self.request_filters
137
+ (@request_filters ||= [])
138
+ end
139
+
140
+ def self.handle(request, method = nil, &block)
141
+ define_contextual_method_from_symbol_or_block(request, method, &block)
142
+ end
143
+
144
+ def self.make_hook(name, parent, params)
145
+ ::AlterEgo::StateHook.new(name, parent, params)
146
+ end
147
+
148
+ def valid_transitions
149
+ self.class.valid_transitions
150
+ end
151
+
152
+ def to_s
153
+ "<State:#{identifier}>"
154
+ end
155
+
156
+ def identifier
157
+ self.class.identifier
158
+ end
159
+
160
+ def ==(other)
161
+ (self.identifier == other) or super(other)
162
+ end
163
+
164
+ def can_handle_request?(request)
165
+ return true if respond_to?(request)
166
+ return false
167
+ end
168
+
169
+ def transition_to(context, request, new_state, *args)
170
+ return true if context.state == new_state
171
+ new_state_obj = context.state_for_identifier(new_state)
172
+ unless new_state_obj
173
+ raise(InvalidTransitionError,
174
+ "Context #{context.inspect} has no state '#{new_state}' defined")
175
+ end
176
+
177
+ continue = context.execute_request_filters(self.class.identifier,
178
+ request,
179
+ new_state)
180
+ return false unless continue
181
+
182
+ unless valid_transitions.empty? || valid_transitions.include?(new_state)
183
+ raise(InvalidTransitionError,
184
+ "Not allowed to transition from #{self.identifier} to #{new_state}")
185
+ end
186
+
187
+ execute_hook(:on_exit, context)
188
+ new_state_obj.execute_hook(:on_enter, context)
189
+ context.state = new_state
190
+ assert(new_state == context.state)
191
+ true
192
+ end
193
+
194
+ protected
195
+
196
+ define_hook :on_enter, :context
197
+ define_hook :on_exit, :context
198
+
199
+ private
200
+
201
+ def self.add_request_filter(request_pattern, new_state_pattern, action)
202
+ new_filter = RequestFilter.new(identifier, request_pattern, new_state_pattern, action)
203
+ self.request_filters << new_filter
204
+ end
205
+
206
+ def self.define_contextual_method_from_symbol_or_block(name, symbol, &block)
207
+ if symbol
208
+ define_method(name) do |context, *args|
209
+ context.send(symbol, *args)
210
+ end
211
+ elsif block
212
+ define_method(name) do |context, *args|
213
+ context.send(:instance_eval, &block)
214
+ end
215
+ end
216
+ end
217
+ end
218
+
219
+ module ClassMethods
220
+ def state(identifier, options={}, &block)
221
+ if states.has_key?(identifier)
222
+ raise InvalidDefinitionError, "State #{identifier.inspect} already defined"
223
+ end
224
+ new_state = Class.new(State)
225
+ new_state_eigenclass = class << new_state; self; end
226
+ new_state_eigenclass.send(:define_method, :identifier) { identifier }
227
+ new_state.instance_eval(&block) if block
228
+
229
+ add_state(new_state, identifier, options)
230
+ end
231
+
232
+ def request_filter(options, &block)
233
+ options.assert_valid_keys(:state, :request, :new_state, :action)
234
+ options = {
235
+ :state => not_nil,
236
+ :request => not_nil,
237
+ :new_state => nil
238
+ }.merge(options)
239
+ add_request_filter(options[:state],
240
+ options[:request],
241
+ options[:new_state],
242
+ AlterEgo.proc_from_symbol_or_block(options[:method], &block))
243
+ end
244
+
245
+ def all_handled_requests
246
+ methods = @state_proxy.public_instance_methods(false)
247
+ methods -= ["identifier", "on_enter", "on_exit"]
248
+ methods.map{|m| m.to_sym}
249
+ end
250
+
251
+ def states
252
+ (@states ||= {})
253
+ end
254
+
255
+ def states=(value)
256
+ @states = value
257
+ end
258
+
259
+ def add_state(new_state, identifier=new_state.identifier, options = {})
260
+ options.assert_valid_keys(:default)
261
+
262
+ self.states[identifier] = new_state.new
263
+
264
+ if options[:default]
265
+ if @default_state
266
+ raise InvalidDefinitionError, "Cannot have more than one default state"
267
+ end
268
+ @default_state = identifier
269
+ end
270
+
271
+ new_requests = (new_state.handled_requests - all_handled_requests)
272
+ new_requests.each do |request|
273
+ @state_proxy.send(:define_method, request) do |*args|
274
+ args.unshift(self)
275
+ begin
276
+ continue = execute_request_filters(current_state.identifier,
277
+ request,
278
+ nil)
279
+ return false unless continue
280
+ current_state.send(request, *args)
281
+ rescue NoMethodError => error
282
+ if error.name.to_s == request.to_s
283
+ raise WrongStateError,
284
+ "Request '#{request}' not supported by state #{current_state}"
285
+ else
286
+ raise
287
+ end
288
+ end
289
+ end
290
+ end
291
+
292
+ self.request_filters += new_state.request_filters
293
+ end
294
+
295
+ def add_request_filter(state_pattern, request_pattern, new_state_pattern, action)
296
+ @request_filters << RequestFilter.new(state_pattern,
297
+ request_pattern,
298
+ new_state_pattern,
299
+ action)
300
+ end
301
+
302
+ def default_state
303
+ @default_state
304
+ end
305
+
306
+ def request_filters
307
+ (@request_filters ||= [])
308
+ end
309
+
310
+ protected
311
+
312
+ def request_filters=(value)
313
+ @request_filters = value
314
+ end
315
+
316
+ def any
317
+ AlterEgo::AnyMatcher.instance
318
+ end
319
+
320
+ def not_nil
321
+ AlterEgo::NotNilMatcher.instance
322
+ end
323
+
324
+ end # End ClassMethods
325
+
326
+ def self.append_features(klass)
327
+ # Give the other module my instance methods at the class level
328
+ klass.extend(ClassMethods)
329
+ klass.extend(Forwardable)
330
+
331
+ state_proxy = Module.new
332
+ klass.instance_variable_set :@state_proxy, state_proxy
333
+ klass.send(:include, state_proxy)
334
+
335
+ super(klass)
336
+ end
337
+
338
+ def current_state
339
+ state_id = self.state
340
+ state_id ? self.class.states[state_id] : nil
341
+ end
342
+
343
+ def state
344
+ result = (@state || self.class.default_state)
345
+ assert(result.nil? || self.class.states.keys.include?(result))
346
+ result
347
+ end
348
+
349
+ def state=(identifier)
350
+ @state = identifier
351
+ end
352
+
353
+ def state_for_identifier(identifier)
354
+ self.class.states[identifier]
355
+ end
356
+
357
+ def transition_to(new_state, request=nil, *args)
358
+ current_state.transition_to(self, request, new_state, *args)
359
+ end
360
+
361
+ def all_handled_requests
362
+ self.class.all_handled_requests
363
+ end
364
+
365
+ def execute_request_filters(state, request, new_state)
366
+ pattern = RequestFilter.new(state, request, new_state)
367
+ self.class.request_filters.grep(pattern) do |filter|
368
+ result = catch(:cancel) do
369
+ self.instance_eval(&filter.action)
370
+ true
371
+ end
372
+ return false unless result
373
+ end
374
+ true
375
+ end
376
+
377
+ def self.proc_from_symbol_or_block(symbol = nil, &block)
378
+ if symbol then
379
+ proc do
380
+ self.send(symbol)
381
+ end
382
+ elsif block then
383
+ block
384
+ else raise "Should never get here"
385
+ end
386
+ end
387
+
388
+ end
data/lib/hash/keys.rb ADDED
@@ -0,0 +1,52 @@
1
+ module ActiveSupport #:nodoc:
2
+ module CoreExtensions #:nodoc:
3
+ module Hash #:nodoc:
4
+ module Keys
5
+ # Return a new hash with all keys converted to strings.
6
+ def stringify_keys
7
+ inject({}) do |options, (key, value)|
8
+ options[key.to_s] = value
9
+ options
10
+ end
11
+ end
12
+
13
+ # Destructively convert all keys to strings.
14
+ def stringify_keys!
15
+ keys.each do |key|
16
+ self[key.to_s] = delete(key)
17
+ end
18
+ self
19
+ end
20
+
21
+ # Return a new hash with all keys converted to symbols.
22
+ def symbolize_keys
23
+ inject({}) do |options, (key, value)|
24
+ options[(key.to_sym rescue key) || key] = value
25
+ options
26
+ end
27
+ end
28
+
29
+ # Destructively convert all keys to symbols.
30
+ def symbolize_keys!
31
+ self.replace(self.symbolize_keys)
32
+ end
33
+
34
+ alias_method :to_options, :symbolize_keys
35
+ alias_method :to_options!, :symbolize_keys!
36
+
37
+ # Validate all keys in a hash match *valid keys, raising ArgumentError on a mismatch.
38
+ # Note that keys are NOT treated indifferently, meaning if you use strings for keys but assert symbols
39
+ # as keys, this will fail.
40
+ #
41
+ # ==== Examples:
42
+ # { :name => "Rob", :years => "28" }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key(s): years"
43
+ # { :name => "Rob", :age => "28" }.assert_valid_keys("name", "age") # => raises "ArgumentError: Unknown key(s): name, age"
44
+ # { :name => "Rob", :age => "28" }.assert_valid_keys(:name, :age) # => passes, raises nothing
45
+ def assert_valid_keys(*valid_keys)
46
+ unknown_keys = keys - [valid_keys].flatten
47
+ raise(ArgumentError, "Unknown key(s): #{unknown_keys.join(", ")}") unless unknown_keys.empty?
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/hash.rb ADDED
@@ -0,0 +1,7 @@
1
+ %w(keys).each do |ext|
2
+ load "hash/#{ext}.rb"
3
+ end
4
+
5
+ class Hash #:nodoc:
6
+ include ActiveSupport::CoreExtensions::Hash::Keys
7
+ end
data/script/console ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # File: script/console
3
+ irb = RUBY_PLATFORM =~ /(:?mswin|mingw)/ ? 'irb.bat' : 'irb'
4
+
5
+ libs = " -r irb/completion"
6
+ # Perhaps use a console_lib to store any extra methods I may want available in the cosole
7
+ # libs << " -r #{File.dirname(__FILE__) + '/../lib/console_lib/console_logger.rb'}"
8
+ libs << " -r #{File.dirname(__FILE__) + '/../lib/alter_ego.rb'}"
9
+ puts "Loading AlterEgo gem"
10
+ exec "#{irb} #{libs} --simple-prompt"