methodical 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.
- data/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +20 -0
- data/README.rdoc +17 -0
- data/Rakefile +49 -0
- data/VERSION +1 -0
- data/lib/methodical.rb +1 -0
- data/lib/methodical/action_item.rb +139 -0
- data/lib/methodical/checklist.rb +29 -0
- data/lib/methodical/disposition.rb +132 -0
- data/lib/methodical/dsl.rb +111 -0
- data/lib/methodical/executable.rb +61 -0
- data/lib/methodical/modifier.rb +43 -0
- data/lib/methodical/simple_action_item.rb +30 -0
- data/lib/methodical/walkthrough.rb +143 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +9 -0
- data/test/methodical/checklist_test.rb +79 -0
- data/test/methodical/disposition_test.rb +120 -0
- data/test/methodical/dsl_test.rb +217 -0
- data/test/methodical/modifier_test.rb +79 -0
- data/test/methodical/simple_action_item_test.rb +825 -0
- data/test/methodical/walkthrough_test.rb +387 -0
- data/test/test_helper.rb +2 -0
- metadata +147 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Avdi Grimm
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
= methodical
|
2
|
+
|
3
|
+
Description goes here.
|
4
|
+
|
5
|
+
== Note on Patches/Pull Requests
|
6
|
+
|
7
|
+
* Fork the project.
|
8
|
+
* Make your feature addition or bug fix.
|
9
|
+
* Add tests for it. This is important so I don't break it in a
|
10
|
+
future version unintentionally.
|
11
|
+
* Commit, do not mess with rakefile, version, or history.
|
12
|
+
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
13
|
+
* Send me a pull request. Bonus points for topic branches.
|
14
|
+
|
15
|
+
== Copyright
|
16
|
+
|
17
|
+
Copyright (c) 2010 Avdi Grimm. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "methodical"
|
8
|
+
gem.summary = %Q{Automation framework for sequential operations}
|
9
|
+
gem.description = %Q{Sorry, no description yet}
|
10
|
+
gem.email = "avdi@avdi.org"
|
11
|
+
gem.homepage = "http://github.com/avdi/methodical"
|
12
|
+
gem.authors = ["Avdi Grimm"]
|
13
|
+
gem.add_dependency "extlib", "~> 0.9.14"
|
14
|
+
gem.add_dependency "arrayfields", "~> 4.7"
|
15
|
+
gem.add_development_dependency "rspec", ">= 1.2.9"
|
16
|
+
gem.add_development_dependency "mocha", "~> 0.9.8"
|
17
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
18
|
+
end
|
19
|
+
Jeweler::GemcutterTasks.new
|
20
|
+
rescue LoadError
|
21
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
22
|
+
end
|
23
|
+
|
24
|
+
require 'spec/rake/spectask'
|
25
|
+
Spec::Rake::SpecTask.new(:spec) do |spec|
|
26
|
+
spec.libs << 'lib' << 'spec'
|
27
|
+
spec.spec_files = FileList['spec/**/*_spec.rb']
|
28
|
+
spec.spec_files += FileList['test/**/*_test.rb']
|
29
|
+
end
|
30
|
+
|
31
|
+
Spec::Rake::SpecTask.new(:rcov) do |spec|
|
32
|
+
spec.libs << 'lib' << 'spec'
|
33
|
+
spec.pattern = 'spec/**/*_spec.rb'
|
34
|
+
spec.rcov = true
|
35
|
+
end
|
36
|
+
|
37
|
+
task :spec => :check_dependencies
|
38
|
+
|
39
|
+
task :default => :spec
|
40
|
+
|
41
|
+
require 'rake/rdoctask'
|
42
|
+
Rake::RDocTask.new do |rdoc|
|
43
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
44
|
+
|
45
|
+
rdoc.rdoc_dir = 'rdoc'
|
46
|
+
rdoc.title = "methodical #{version}"
|
47
|
+
rdoc.rdoc_files.include('README*')
|
48
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
49
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.1
|
data/lib/methodical.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'extlib'
|
@@ -0,0 +1,139 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
require 'methodical/disposition'
|
3
|
+
require 'methodical/executable'
|
4
|
+
|
5
|
+
module Methodical
|
6
|
+
class ActionItem
|
7
|
+
include Executable
|
8
|
+
extend Forwardable
|
9
|
+
|
10
|
+
attr_reader :title
|
11
|
+
attr_reader :error
|
12
|
+
attr_writer :ignored
|
13
|
+
attr_reader :disposition
|
14
|
+
attr_accessor :walkthrough
|
15
|
+
|
16
|
+
def_delegators :disposition,
|
17
|
+
:status,
|
18
|
+
:status=,
|
19
|
+
:explanation,
|
20
|
+
:explanation=,
|
21
|
+
:result,
|
22
|
+
:result=,
|
23
|
+
:error,
|
24
|
+
:error=,
|
25
|
+
:details,
|
26
|
+
:details=,
|
27
|
+
:succeeded?,
|
28
|
+
:failed?,
|
29
|
+
:bad?,
|
30
|
+
:done?,
|
31
|
+
:halted?,
|
32
|
+
:done_and_ok?
|
33
|
+
|
34
|
+
def initialize(title)
|
35
|
+
@title = title
|
36
|
+
@ignored = false
|
37
|
+
@disposition = Disposition.new([:not_started, "", nil])
|
38
|
+
@raise_on_error = false
|
39
|
+
end
|
40
|
+
|
41
|
+
def to_s
|
42
|
+
synopsis
|
43
|
+
end
|
44
|
+
|
45
|
+
def synopsis
|
46
|
+
"#{title}: #{human_status}" +
|
47
|
+
(explanation.blank? ? "." : " (#{explanation})#{ignored_suffix}.")
|
48
|
+
end
|
49
|
+
|
50
|
+
def inspect
|
51
|
+
"##<#{self.class.name}:#{title}:#{object_id}>"
|
52
|
+
end
|
53
|
+
|
54
|
+
def human_status
|
55
|
+
case status
|
56
|
+
when :failed, :abort then "Failed"
|
57
|
+
when :bad then "Error"
|
58
|
+
when :succeeded, :sufficient, :finish then "OK"
|
59
|
+
when :in_progress then "In progress"
|
60
|
+
when :not_started then "Not started"
|
61
|
+
when :skipped then "Skipped"
|
62
|
+
else raise "Invalid status #{status.inspect}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def ignored?
|
67
|
+
@ignored
|
68
|
+
end
|
69
|
+
|
70
|
+
def relevant?
|
71
|
+
!ignored?
|
72
|
+
end
|
73
|
+
|
74
|
+
def continue?
|
75
|
+
disposition.continuable?
|
76
|
+
end
|
77
|
+
|
78
|
+
def decisive?
|
79
|
+
!ignored? && disposition.decisive?
|
80
|
+
end
|
81
|
+
|
82
|
+
def update!(status, explanation, result, error=nil, details="")
|
83
|
+
self.status = status
|
84
|
+
self.explanation = explanation
|
85
|
+
self.result = result
|
86
|
+
self.error = error
|
87
|
+
self.details = details
|
88
|
+
disposition
|
89
|
+
end
|
90
|
+
|
91
|
+
# Disposition methods
|
92
|
+
def succeed!(explanation="", result=nil, details="")
|
93
|
+
throw(:methodical_disposition,
|
94
|
+
Methodical::Disposition(:succeeded, explanation, result, nil, details))
|
95
|
+
end
|
96
|
+
|
97
|
+
def fail!(explanation="", result=nil, error=nil, details="")
|
98
|
+
throw(:methodical_disposition,
|
99
|
+
Methodical::Disposition(:failed, explanation, result, error, details))
|
100
|
+
end
|
101
|
+
|
102
|
+
def skip!(explanation="", details="")
|
103
|
+
throw(:methodical_disposition,
|
104
|
+
Methodical::Disposition(:skipped, explanation, nil, nil, details))
|
105
|
+
end
|
106
|
+
|
107
|
+
def checkpoint!(explanation="", memento=nil, details="")
|
108
|
+
throw(:methodical_disposition,
|
109
|
+
Methodical::Disposition(:in_progress, explanation, memento, nil, details))
|
110
|
+
end
|
111
|
+
|
112
|
+
def sufficient!(explanation="", result=nil, details="")
|
113
|
+
throw(:methodical_disposition,
|
114
|
+
Methodical::Disposition(:sufficient, explanation, result, nil, details))
|
115
|
+
end
|
116
|
+
|
117
|
+
def finish!(explanation="", result=nil, details="")
|
118
|
+
throw(:methodical_disposition,
|
119
|
+
Methodical::Disposition(:finish, explanation, result, nil, details))
|
120
|
+
end
|
121
|
+
|
122
|
+
def abort!(explanation="", result=nil, error=nil, details="")
|
123
|
+
throw(:methodical_disposition,
|
124
|
+
Methodical::Disposition(:abort, explanation, result, error, details))
|
125
|
+
end
|
126
|
+
|
127
|
+
protected
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
def ignored_suffix
|
132
|
+
if ignored? && failed?
|
133
|
+
" (Ignored)"
|
134
|
+
else
|
135
|
+
""
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'delegate'
|
2
|
+
require 'methodical/walkthrough'
|
3
|
+
|
4
|
+
module Methodical
|
5
|
+
class Checklist < DelegateClass(Array)
|
6
|
+
attr_reader :title
|
7
|
+
|
8
|
+
def initialize(title)
|
9
|
+
@title = title
|
10
|
+
super([])
|
11
|
+
end
|
12
|
+
|
13
|
+
def new_walkthrough
|
14
|
+
Walkthrough.new(self)
|
15
|
+
end
|
16
|
+
|
17
|
+
def perform_walkthrough!(baton=nil, raise_on_error=false, &block)
|
18
|
+
walkthrough = new_walkthrough
|
19
|
+
walkthrough.perform!(baton, raise_on_error, &block)
|
20
|
+
walkthrough
|
21
|
+
end
|
22
|
+
|
23
|
+
def <<(object)
|
24
|
+
raise ArgumentError, "No nils allowed" if object.nil?
|
25
|
+
super(object)
|
26
|
+
object
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'arrayfields'
|
2
|
+
|
3
|
+
module Methodical
|
4
|
+
def self.Disposition(*args)
|
5
|
+
if args.size == 1
|
6
|
+
object = args.first
|
7
|
+
if Disposition === object
|
8
|
+
object
|
9
|
+
elsif object.kind_of?(Array) &&
|
10
|
+
object.size >= 2 &&
|
11
|
+
object[0].kind_of?(Symbol) &&
|
12
|
+
object[1].kind_of?(String)
|
13
|
+
Disposition.new(object)
|
14
|
+
else
|
15
|
+
Disposition.new([:succeeded, "", object])
|
16
|
+
end
|
17
|
+
else
|
18
|
+
Disposition.new(args)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
Disposition = Array.struct :status, :explanation, :result, :error, :details
|
23
|
+
|
24
|
+
# A Disposition represents the status of an ActionItem (step)
|
25
|
+
#
|
26
|
+
# Explanation of statuses:
|
27
|
+
#
|
28
|
+
# * not_started: The action has not yet been performed
|
29
|
+
# * in_progress: The action has been started, and has not finished.
|
30
|
+
# * succeeded: The action succeeded. If all actions succeed, the walkthrough
|
31
|
+
# is considered a success.
|
32
|
+
# * failed: The action failed. The walkthrough will fail unless the
|
33
|
+
# "ignored" flag was set; but the walkthrough will not be
|
34
|
+
# halted.
|
35
|
+
# * sufficient: The action succeeded. Later steps will be executed, but
|
36
|
+
# the walkthrough will be a success even if there are later
|
37
|
+
# failures.
|
38
|
+
# * finish: The action succeeded. No more steps will be performed.
|
39
|
+
# * abort: The action failed, and no more steps will be performed.
|
40
|
+
# * bad: An error occured outside of the range of any expected failure
|
41
|
+
# modes. The walkthrough will continue, but will be marked as
|
42
|
+
# failed.
|
43
|
+
# * skipped: The action was skipped. The "explanation" field should
|
44
|
+
# contain the reason for skipping the action.
|
45
|
+
class Disposition
|
46
|
+
VALID_STATUSES = [
|
47
|
+
:not_started,
|
48
|
+
:in_progress,
|
49
|
+
:succeeded,
|
50
|
+
:failed,
|
51
|
+
:sufficient,
|
52
|
+
:finish,
|
53
|
+
:abort,
|
54
|
+
:bad,
|
55
|
+
:skipped
|
56
|
+
]
|
57
|
+
|
58
|
+
alias_method :base_initialize, :initialize
|
59
|
+
def initialize(*args)
|
60
|
+
base_initialize(*args)
|
61
|
+
self.details ||= ""
|
62
|
+
validate!
|
63
|
+
end
|
64
|
+
|
65
|
+
alias_method :memento, :result
|
66
|
+
alias_method :memento=, :result=
|
67
|
+
|
68
|
+
def ok?
|
69
|
+
!failed?
|
70
|
+
end
|
71
|
+
|
72
|
+
def failed?
|
73
|
+
[:failed, :bad, :abort].include?(status)
|
74
|
+
end
|
75
|
+
|
76
|
+
def succeeded?
|
77
|
+
[:succeeded, :sufficient, :finish].include?(status)
|
78
|
+
end
|
79
|
+
|
80
|
+
def bad?
|
81
|
+
status == :bad
|
82
|
+
end
|
83
|
+
|
84
|
+
def skipped?
|
85
|
+
status == :skipped
|
86
|
+
end
|
87
|
+
|
88
|
+
def done?
|
89
|
+
succeeded? || failed? || skipped?
|
90
|
+
end
|
91
|
+
|
92
|
+
def continuable?
|
93
|
+
!halted?
|
94
|
+
end
|
95
|
+
|
96
|
+
def halted?
|
97
|
+
status == :abort || status == :finish
|
98
|
+
end
|
99
|
+
|
100
|
+
def decisive?
|
101
|
+
[:sufficient, :finish, :failed, :bad, :abort].include?(status)
|
102
|
+
end
|
103
|
+
|
104
|
+
def done_and_ok?
|
105
|
+
done? && ok?
|
106
|
+
end
|
107
|
+
|
108
|
+
def merge(params)
|
109
|
+
params.inject(self.class.new(self)) {|d, (k,v)| d[k] = v; d}
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def validate!
|
115
|
+
unless VALID_STATUSES.include?(status)
|
116
|
+
raise ArgumentError, "Invalid status #{status.inspect}"
|
117
|
+
end
|
118
|
+
unless explanation.kind_of?(String)
|
119
|
+
raise ArgumentError, "Explanation must be a String"
|
120
|
+
end
|
121
|
+
if result.kind_of?(Exception)
|
122
|
+
raise ArgumentError, "Result must not be an Exception"
|
123
|
+
end
|
124
|
+
if error && !error.kind_of?(Exception)
|
125
|
+
raise ArgumentError, "Error must be an Exception"
|
126
|
+
end
|
127
|
+
unless details.kind_of?(String)
|
128
|
+
raise ArgumentError, "Details must be a String"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'methodical/simple_action_item'
|
2
|
+
require 'methodical/modifier'
|
3
|
+
|
4
|
+
module Methodical
|
5
|
+
module DSL
|
6
|
+
def action(title, &block)
|
7
|
+
SimpleActionItem.new(title, &block)
|
8
|
+
end
|
9
|
+
|
10
|
+
def sufficient
|
11
|
+
Modifier.new("Sufficient") do |action_item, baton|
|
12
|
+
disposition = action_item.execute!(baton)
|
13
|
+
if(disposition.status == :succeeded)
|
14
|
+
disposition.merge(:status => :sufficient)
|
15
|
+
else
|
16
|
+
disposition
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def requisite
|
22
|
+
Modifier.new("Requisite") do |action_item, baton|
|
23
|
+
disposition = action_item.execute!(baton)
|
24
|
+
if(disposition.failed?)
|
25
|
+
disposition.merge(:status => :abort)
|
26
|
+
else
|
27
|
+
disposition
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def skip_if(reason, &block)
|
33
|
+
Modifier.new("Skip if #{reason}") do |action_item, baton|
|
34
|
+
if block.call(baton, action_item)
|
35
|
+
action_item.skip!(reason)
|
36
|
+
else
|
37
|
+
action_item.call(baton, action_item)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def handle_error(error_type, &block)
|
43
|
+
Modifier.new("Handle error #{error_type}") do |action_item, baton|
|
44
|
+
begin
|
45
|
+
action_item.execute!(baton, true)
|
46
|
+
rescue error_type => error
|
47
|
+
block.call(baton, action_item, error)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def recover_failure
|
53
|
+
Modifier.new("Recover from failure") do |action_item, baton|
|
54
|
+
disposition = action_item.execute!(baton)
|
55
|
+
if disposition.status == :failed
|
56
|
+
yield(baton, action_item, disposition)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def ignore
|
62
|
+
Modifier.new("Ignore failures") do |action_item, baton|
|
63
|
+
action_item.ignored=true
|
64
|
+
action_item.call(baton, action_item)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Filter and optionally modify step disposition
|
69
|
+
def filter(&block)
|
70
|
+
Modifier.new("Filter disposition") do |action_item, baton|
|
71
|
+
block.call(action_item.execute!(baton))
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# TODO Factor this out into its own class, it's a bit big
|
76
|
+
# TODO we may want to roll this functionality into the core Checklist. It
|
77
|
+
# would be nice if a retried action would actually show up in the log, e.g.:
|
78
|
+
# 8. Do some work (Failed; Retrying)
|
79
|
+
# 8. Do some work (Succeeded)
|
80
|
+
def retry_on_failure(
|
81
|
+
times_to_retry=1, time_limit_in_seconds=:none, options={})
|
82
|
+
max_tries = times_to_retry.to_i + 1
|
83
|
+
clock = options.fetch(:clock) { Time }
|
84
|
+
cutoff_time = if time_limit_in_seconds == :none
|
85
|
+
:none
|
86
|
+
else
|
87
|
+
clock.now + time_limit_in_seconds
|
88
|
+
end
|
89
|
+
description =
|
90
|
+
"Retry #{times_to_retry} times"
|
91
|
+
unless time_limit_in_seconds == :none
|
92
|
+
description << " or #{time_limit_in_seconds} seconds"
|
93
|
+
end
|
94
|
+
Modifier.new(description) do
|
95
|
+
|action_item, baton|
|
96
|
+
tries = 0
|
97
|
+
begin
|
98
|
+
disposition = action_item.execute!(baton)
|
99
|
+
tries += 1
|
100
|
+
if disposition.failed? &&
|
101
|
+
(cutoff_time != :none) &&
|
102
|
+
(clock.now >= cutoff_time)
|
103
|
+
break
|
104
|
+
end
|
105
|
+
end until disposition.succeeded? || tries >= max_tries
|
106
|
+
disposition
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
end
|