laborantin 0.0.14 → 0.0.21
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/INFO +1 -0
- data/README +148 -4
- data/Rakefile +73 -27
- data/TODO +27 -6
- data/bin/labor +2 -404
- data/lib/laborantin.rb +13 -26
- data/lib/laborantin/core/analysis.rb +231 -0
- data/lib/laborantin/core/command.rb +234 -0
- data/lib/laborantin/core/completeable.rb +30 -0
- data/lib/laborantin/core/configurable.rb +28 -0
- data/lib/laborantin/core/datable.rb +13 -0
- data/lib/laborantin/core/describable.rb +11 -0
- data/lib/laborantin/core/environment.rb +90 -52
- data/lib/laborantin/core/hookable.rb +20 -0
- data/lib/laborantin/core/monkey_patches.rb +0 -1
- data/lib/laborantin/core/multi_name.rb +25 -0
- data/lib/laborantin/core/parameter.rb +5 -12
- data/lib/laborantin/core/parameter_hash.rb +6 -2
- data/lib/laborantin/core/scenario.rb +93 -70
- data/lib/laborantin/core/table.rb +84 -0
- data/lib/laborantin/extra/commands/git.rb +40 -0
- data/lib/laborantin/extra/commands/git/check.rb +25 -0
- data/lib/laborantin/extra/commands/git/run.rb +100 -0
- data/lib/laborantin/extra/vectorial_product.rb +31 -0
- data/lib/laborantin/runner.rb +247 -0
- data/lib/laborantin/runner/commands/analyze.rb +58 -0
- data/lib/laborantin/runner/commands/cleanup.rb +40 -0
- data/lib/laborantin/runner/commands/complete.rb +111 -0
- data/lib/laborantin/runner/commands/config.rb +169 -0
- data/lib/laborantin/runner/commands/create.rb +61 -0
- data/lib/laborantin/runner/commands/describe.rb +215 -0
- data/lib/laborantin/runner/commands/find.rb +82 -0
- data/lib/laborantin/runner/commands/load_classes.rb +75 -0
- data/lib/laborantin/runner/commands/load_results.rb +143 -0
- data/lib/laborantin/runner/commands/note.rb +35 -0
- data/lib/laborantin/runner/commands/replay.rb +89 -0
- data/lib/laborantin/runner/commands/rm.rb +107 -0
- data/lib/laborantin/runner/commands/run.rb +131 -0
- data/lib/laborantin/runner/commands/scan.rb +77 -0
- metadata +45 -13
- data/bin/files/README.erb +0 -29
- data/bin/files/TODO.erb +0 -2
- data/bin/files/config/ftp.yaml.erb +0 -6
- data/bin/files/config/xmpp.yaml.erb +0 -7
- data/bin/files/environments/environment.rb.erb +0 -10
- data/bin/files/scenarii/scenario.rb.erb +0 -13
@@ -0,0 +1,84 @@
|
|
1
|
+
module Laborantin
|
2
|
+
class Table
|
3
|
+
attr_reader :name, :struct, :path
|
4
|
+
attr_accessor :separator, :comment, :header
|
5
|
+
def initialize(name, struct=nil, path=nil)
|
6
|
+
@name = name
|
7
|
+
@struct = struct
|
8
|
+
@path = path
|
9
|
+
@separator = ' '
|
10
|
+
@comment = '#'
|
11
|
+
@heaer = nil
|
12
|
+
yield self if block_given?
|
13
|
+
end
|
14
|
+
|
15
|
+
def fields
|
16
|
+
struct.members.map(&:to_s)
|
17
|
+
end
|
18
|
+
|
19
|
+
def header
|
20
|
+
@header || [comment, fields].flatten.join(separator)
|
21
|
+
end
|
22
|
+
|
23
|
+
class Filler < Proc
|
24
|
+
alias :<< :call
|
25
|
+
end
|
26
|
+
|
27
|
+
def fill(&blk)
|
28
|
+
File.open(path, 'w') do |f|
|
29
|
+
f.puts header if struct
|
30
|
+
filler = Filler.new do |val|
|
31
|
+
f.puts dump(val)
|
32
|
+
end
|
33
|
+
blk.call(filler)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def dump(obj, check=true)
|
38
|
+
strings = obj.to_a.map(&:to_s)
|
39
|
+
if check
|
40
|
+
if strings.find{|s| s.include?(separator)}
|
41
|
+
raise ArgumentError, "cannot unambiguously dump a value with the separator"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
line = strings.join(separator)
|
45
|
+
if check
|
46
|
+
if line.start_with?(comment)
|
47
|
+
raise ArgumentError, "line starting with comment\n#{line}"
|
48
|
+
end
|
49
|
+
expected = struct.members.size
|
50
|
+
got = line.split(separator).size
|
51
|
+
if got != expected
|
52
|
+
raise ArgumentError, "ambiguous line: #{got} fields instead of #{expected} in \n#{line}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
line
|
56
|
+
end
|
57
|
+
|
58
|
+
def read(hash={})
|
59
|
+
if block_given?
|
60
|
+
File.open(path) do |f|
|
61
|
+
f.each_line do |l|
|
62
|
+
next if l.start_with?(comment)
|
63
|
+
strings = l.chomp.split(separator)
|
64
|
+
pairs = [struct.members, strings].transpose
|
65
|
+
|
66
|
+
atoms = pairs.map do |sym, val|
|
67
|
+
int = hash[sym]
|
68
|
+
if int
|
69
|
+
val.send(int)
|
70
|
+
else
|
71
|
+
val
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
yield struct.new(*atoms)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
else
|
79
|
+
Enumerator.new(self, :read, hash)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
|
2
|
+
require 'git'
|
3
|
+
require 'laborantin/extra/commands/git/check'
|
4
|
+
require 'laborantin/extra/commands/git/run'
|
5
|
+
|
6
|
+
module Laborantin
|
7
|
+
module Commands
|
8
|
+
# A Git set of Commands, to enable it, pass to true the :extra :git flag.
|
9
|
+
module Git
|
10
|
+
|
11
|
+
HERE = '.'
|
12
|
+
|
13
|
+
# Just get a git object for current working dir (implementation is crappy, but will be refined later)
|
14
|
+
def self.git
|
15
|
+
@g ||= Object::Git.open(HERE)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Get the current working directory branch.
|
19
|
+
def self.branch
|
20
|
+
self.git.current_branch
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get the commit-id (long sha1) where current branch is pointing to.
|
24
|
+
def self.commit_id
|
25
|
+
self.git.revparse(branch)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns true if current working dir is master.
|
29
|
+
def self.master_branch?
|
30
|
+
'master' == branch
|
31
|
+
end
|
32
|
+
|
33
|
+
# Returns the Diff between current directory and the HEAD of current
|
34
|
+
# branch.
|
35
|
+
def self.diff
|
36
|
+
git.diff(commit_id, HERE)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
|
2
|
+
module Laborantin
|
3
|
+
module Commands
|
4
|
+
module Git
|
5
|
+
class Check < Laborantin::Command
|
6
|
+
describe "Check if you're in a git repo, and master"
|
7
|
+
plumbery!
|
8
|
+
|
9
|
+
execute do
|
10
|
+
unless Laborantin::Commands::Git.master_branch?
|
11
|
+
puts "Cannot run unless you're in the master branch"
|
12
|
+
exit
|
13
|
+
end
|
14
|
+
|
15
|
+
if Laborantin::Commands::Git.diff.stats[:total][:files] > 0
|
16
|
+
puts "There are uncommited changes"
|
17
|
+
exit
|
18
|
+
end
|
19
|
+
|
20
|
+
Laborantin::Commands::Git
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
|
2
|
+
module Laborantin
|
3
|
+
module Commands
|
4
|
+
module Git
|
5
|
+
class Run < Laborantin::Command
|
6
|
+
|
7
|
+
describe "like run, with git integration before"
|
8
|
+
|
9
|
+
option(:scenarii) do
|
10
|
+
describe "comma separated list of scenarios to describe"
|
11
|
+
short "-s"
|
12
|
+
long "--scenarii=OPTIONAL"
|
13
|
+
type Array
|
14
|
+
default []
|
15
|
+
end
|
16
|
+
|
17
|
+
option(:environments) do
|
18
|
+
describe "comma separated list of environments to describe"
|
19
|
+
short "-e"
|
20
|
+
long "--envs=OPTIONAL"
|
21
|
+
type Array
|
22
|
+
default []
|
23
|
+
end
|
24
|
+
|
25
|
+
option(:parameters) do
|
26
|
+
describe "filter for parameters (a hash as Ruby syntax code)"
|
27
|
+
short '-p'
|
28
|
+
long '--parameters=OPTIONAL'
|
29
|
+
type String
|
30
|
+
default ''
|
31
|
+
end
|
32
|
+
|
33
|
+
option(:analyze) do
|
34
|
+
describe "set this flag to analyze as you run"
|
35
|
+
short '-a'
|
36
|
+
long '--analyze'
|
37
|
+
default false
|
38
|
+
end
|
39
|
+
|
40
|
+
option(:force) do
|
41
|
+
describe "set this flag to prevent git branch verification"
|
42
|
+
short '-f'
|
43
|
+
long '--force'
|
44
|
+
default false
|
45
|
+
end
|
46
|
+
|
47
|
+
execute do
|
48
|
+
|
49
|
+
Laborantin::Commands::Git::Check.new().run unless opts[:force]
|
50
|
+
|
51
|
+
git = Laborantin::Commands::Git
|
52
|
+
|
53
|
+
puts "Running with #{git.branch} #{git.commit_id}"
|
54
|
+
|
55
|
+
# Parameters parsing
|
56
|
+
params = eval(opts[:parameters]) unless opts[:parameters].empty?
|
57
|
+
params.each_key{|k| params[k] = [params[k]].flatten} if params
|
58
|
+
|
59
|
+
# Environments and Scenarii filtering
|
60
|
+
envs = if opts[:environments].empty?
|
61
|
+
Laborantin::Environment.all
|
62
|
+
else
|
63
|
+
opts[:environments].map!{|e| e.camelize}
|
64
|
+
Laborantin::Environment.all.select{|e| opts[:environments].include? e.name}
|
65
|
+
end
|
66
|
+
|
67
|
+
scii = if opts[:scenarii].empty?
|
68
|
+
Laborantin::Scenario.all
|
69
|
+
else
|
70
|
+
opts[:scenarii].map!{|e| e.camelize}
|
71
|
+
Laborantin::Scenario.all.select{|e| opts[:scenarii].include? e.name}
|
72
|
+
end
|
73
|
+
|
74
|
+
# Actual run of experiments
|
75
|
+
envs.each do |eklass|
|
76
|
+
env = eklass.new
|
77
|
+
env.config[:git] = {:commit => git.commit_id}
|
78
|
+
#TODO: store revision
|
79
|
+
if env.valid?
|
80
|
+
env.prepare!
|
81
|
+
env.log "Running matching scenarii", :info #TODO: pass the logging+running in the env
|
82
|
+
scii.each do |sklass|
|
83
|
+
sklass.parameters.merge! params if params
|
84
|
+
env.log sklass.parameters.inspect
|
85
|
+
sklass.parameters.each_config do |cfg|
|
86
|
+
sc = sklass.new(env, cfg)
|
87
|
+
sc.prepare!
|
88
|
+
sc.perform!
|
89
|
+
sc.analyze! if opts[:analyze]
|
90
|
+
end
|
91
|
+
end
|
92
|
+
env.teardown!
|
93
|
+
env.log "Scenarii performed", :info
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
|
2
|
+
module Laborantin
|
3
|
+
module VectorialProduct
|
4
|
+
def vectors
|
5
|
+
@vectors ||= {}
|
6
|
+
end
|
7
|
+
|
8
|
+
def vector_file(sym)
|
9
|
+
blk = lambda {|f| yield f}
|
10
|
+
if sym == :__raw__
|
11
|
+
raw_result_file &blk
|
12
|
+
else
|
13
|
+
product_file(sym, &blk)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def vector(sym=:__raw__)
|
18
|
+
vectors[sym] ||= read_vector(sym)
|
19
|
+
end
|
20
|
+
|
21
|
+
def read_vector(sym)
|
22
|
+
vals = []
|
23
|
+
vector_file(sym) do |file|
|
24
|
+
file.each_line do |line|
|
25
|
+
vals << line.chomp.to_f
|
26
|
+
end
|
27
|
+
end
|
28
|
+
vals
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,247 @@
|
|
1
|
+
#runner.rb
|
2
|
+
|
3
|
+
=begin
|
4
|
+
|
5
|
+
This file is part of Laborantin.
|
6
|
+
|
7
|
+
Laborantin is free software: you can redistribute it and/or modify
|
8
|
+
it under the terms of the GNU General Public License as published by
|
9
|
+
the Free Software Foundation, either version 3 of the License, or
|
10
|
+
(at your option) any later version.
|
11
|
+
|
12
|
+
Laborantin is distributed in the hope that it will be useful,
|
13
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
14
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
15
|
+
GNU General Public License for more details.
|
16
|
+
|
17
|
+
You should have received a copy of the GNU General Public License
|
18
|
+
along with Laborantin. If not, see <http://www.gnu.org/licenses/>.
|
19
|
+
|
20
|
+
Copyright (c) 2009, Lucas Di Cioccio
|
21
|
+
|
22
|
+
=end
|
23
|
+
|
24
|
+
require 'laborantin/runner/commands/load_classes'
|
25
|
+
require 'laborantin/runner/commands/load_results'
|
26
|
+
require 'laborantin/runner/commands/create'
|
27
|
+
require 'laborantin/runner/commands/complete'
|
28
|
+
require 'laborantin/runner/commands/describe'
|
29
|
+
require 'laborantin/runner/commands/run'
|
30
|
+
require 'laborantin/runner/commands/find'
|
31
|
+
require 'laborantin/runner/commands/scan'
|
32
|
+
require 'laborantin/runner/commands/analyze'
|
33
|
+
require 'laborantin/runner/commands/replay'
|
34
|
+
require 'laborantin/runner/commands/note'
|
35
|
+
require 'laborantin/runner/commands/cleanup'
|
36
|
+
require 'laborantin/runner/commands/rm'
|
37
|
+
require 'laborantin/runner/commands/config'
|
38
|
+
require 'optparse'
|
39
|
+
require 'find'
|
40
|
+
require 'yaml'
|
41
|
+
require 'singleton'
|
42
|
+
require 'laborantin/core/configurable'
|
43
|
+
|
44
|
+
module Laborantin
|
45
|
+
|
46
|
+
class Runner
|
47
|
+
include Metaprog::Configurable
|
48
|
+
include Singleton
|
49
|
+
# The configuration of the Runner, a hash serialized in the laborantin.yaml
|
50
|
+
# file.
|
51
|
+
attr_accessor :config
|
52
|
+
|
53
|
+
# The root_dir is the internal name for the working directory of a
|
54
|
+
# Laborantin's project.
|
55
|
+
attr_accessor :root_dir
|
56
|
+
|
57
|
+
# Initializes the root_dir with the current working directory of the shell
|
58
|
+
# (i.e. '.').
|
59
|
+
def initialize
|
60
|
+
@root_dir = File.expand_path('.')
|
61
|
+
end
|
62
|
+
|
63
|
+
# Provides a shortcut for building the correct directory path inside the root_dir.
|
64
|
+
# Returns a string.
|
65
|
+
# Does not check if the directory exists or is readable or anything.
|
66
|
+
# e.g. dir(:results) or dir('results') to build the path to the result dir.
|
67
|
+
def dir(*sym)
|
68
|
+
File.join(root_dir, sym.map{|s| s.to_s})
|
69
|
+
end
|
70
|
+
|
71
|
+
def resultdir
|
72
|
+
dir(:results)
|
73
|
+
end
|
74
|
+
|
75
|
+
def user_laborantin_dir
|
76
|
+
File.join(File.expand_path('~'), '.laborantin')
|
77
|
+
end
|
78
|
+
|
79
|
+
def file(dir, sym)
|
80
|
+
File.join(dir, sym)
|
81
|
+
end
|
82
|
+
|
83
|
+
def config_path
|
84
|
+
file(dir(:config), 'laborantin.yaml')
|
85
|
+
end
|
86
|
+
|
87
|
+
def load_dir(path)
|
88
|
+
# Verify the presence of the dir, needed for backward
|
89
|
+
# compatibility
|
90
|
+
if File.directory?(path)
|
91
|
+
Object::Find.find(path) do |file|
|
92
|
+
if File.extname(file) == '.rb'
|
93
|
+
require file
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def load_user_commands
|
100
|
+
dir = File.join(user_laborantin_dir, 'commands')
|
101
|
+
load_dir(dir)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Load the ruby files from the dirname directory of a Laborantin project.
|
105
|
+
# Current implementation require all .rb files found in the commands directory.
|
106
|
+
def load_local_dir(dirname)
|
107
|
+
d = dir(dirname)
|
108
|
+
load_dir(d)
|
109
|
+
end
|
110
|
+
|
111
|
+
def load_commands
|
112
|
+
load_local_dir(:commands)
|
113
|
+
end
|
114
|
+
|
115
|
+
def load_environments
|
116
|
+
load_local_dir(:environments)
|
117
|
+
end
|
118
|
+
|
119
|
+
def load_scenarii
|
120
|
+
load_local_dir(:scenarii)
|
121
|
+
end
|
122
|
+
|
123
|
+
def load_analyses
|
124
|
+
load_local_dir(:analyses)
|
125
|
+
end
|
126
|
+
|
127
|
+
def extra_dir
|
128
|
+
File.join('laborantin', 'extra')
|
129
|
+
end
|
130
|
+
|
131
|
+
def load_extra(what, name)
|
132
|
+
require File.join(extra_dir, what.to_s, name.to_s)
|
133
|
+
end
|
134
|
+
|
135
|
+
def load_extra_commands
|
136
|
+
if config and config[:extra].is_a? Hash
|
137
|
+
config[:extra].each_pair do |name, val|
|
138
|
+
load_extra(:commands, name) if val
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Prepare a Runner by loading the configuration and the extra commands.
|
144
|
+
def prepare
|
145
|
+
load_config!
|
146
|
+
load_user_commands
|
147
|
+
load_commands
|
148
|
+
load_environments
|
149
|
+
load_scenarii
|
150
|
+
load_analyses
|
151
|
+
load_extra_commands
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
|
156
|
+
class CliRunner < Runner
|
157
|
+
|
158
|
+
def command_for_argv(argv)
|
159
|
+
Command.sort_by{|c| - argv_klass_name(c).length}.find do |c|
|
160
|
+
invokation = argv_klass_name(c)
|
161
|
+
# does the first words on the CLI match this command's invokation?
|
162
|
+
(argv.slice(0, invokation.size) == invokation)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def argv_klass_name(command_klass)
|
167
|
+
ary = command_klass.command_name.split('::').map{|s| s.duck_case}
|
168
|
+
|
169
|
+
# Strip our default command path
|
170
|
+
['laborantin', 'commands'].each do |prefix|
|
171
|
+
if ary.first == prefix
|
172
|
+
ary = ary[1 .. -1]
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
ary
|
177
|
+
end
|
178
|
+
|
179
|
+
def cli_klass_name(command_klass)
|
180
|
+
argv_klass_name(command_klass).join(' ')
|
181
|
+
end
|
182
|
+
|
183
|
+
def parse_opts(argv, klass)
|
184
|
+
extra_opts = {}
|
185
|
+
|
186
|
+
parser = OptionParser.new do |opt|
|
187
|
+
opt.banner = "Usage: #{File.basename($0)} #{cli_klass_name(klass)} [options...] [args...]"
|
188
|
+
opt.banner << "\n" + klass.description
|
189
|
+
opt.on_tail('-h', '--help', "show this help and exits") {|val| puts opt ; exit}
|
190
|
+
klass.options.each do |arg|
|
191
|
+
opt.on(arg.cli_short, arg.cli_long, arg.description_with_default, arg.default_type) {|val| extra_opts[arg.name] = val}
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
remaining_args = parser.parse!(argv)
|
196
|
+
|
197
|
+
return remaining_args, extra_opts
|
198
|
+
end
|
199
|
+
|
200
|
+
def print_generic_help
|
201
|
+
proposed = Command.reject{|cmd| cmd.plumbery?}
|
202
|
+
parser = OptionParser.new do |opt|
|
203
|
+
opt.banner = "Usage: #{File.basename($0)} <command> [options] [args...]\n"
|
204
|
+
opt.banner << "Run #{File.basename($0)} <command> --help to have help on a command\n"
|
205
|
+
opt.banner << "Known commands are: \n"
|
206
|
+
opt.banner << proposed.map do |klass|
|
207
|
+
line = klass.description.split("\n").first.chomp
|
208
|
+
"\t #{cli_klass_name(klass)} \n\t\t #{line}"
|
209
|
+
end.join("\n")
|
210
|
+
end
|
211
|
+
puts parser
|
212
|
+
end
|
213
|
+
|
214
|
+
# Actually runs the Runner.
|
215
|
+
# This method interprets cli as a command line where cli only contains the arguments
|
216
|
+
# (i.e., without the executable name).
|
217
|
+
# The arguments are splitted as separated by splitter.
|
218
|
+
def run_cli(cli, splitter=' ')
|
219
|
+
run_argv(cli.split(splitter))
|
220
|
+
end
|
221
|
+
|
222
|
+
# Actually runs the Runner, interpreting argv like an ARGV array of strings.
|
223
|
+
def run_argv(arguments)
|
224
|
+
argv = arguments.dup
|
225
|
+
prepare
|
226
|
+
cmd_klass = command_for_argv(argv)
|
227
|
+
if cmd_klass
|
228
|
+
# removes the heading of argv (i.e. the part corresponding to the class name in most of the cases)
|
229
|
+
argv_klass_name(cmd_klass).size.times do
|
230
|
+
argv.shift()
|
231
|
+
end
|
232
|
+
|
233
|
+
begin
|
234
|
+
args, opts = parse_opts(argv, cmd_klass)
|
235
|
+
cmd = cmd_klass.new(self)
|
236
|
+
cmd.run(args, opts)
|
237
|
+
rescue OptionParser::InvalidOption => err
|
238
|
+
puts err.message
|
239
|
+
puts "use 'labor #{argv_klass_name(cmd_klass)} --help' for help"
|
240
|
+
end
|
241
|
+
else
|
242
|
+
print_generic_help
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|