poise-python 1.0.0
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 +11 -0
- data/.kitchen.travis.yml +9 -0
- data/.kitchen.yml +8 -0
- data/.travis.yml +21 -0
- data/.yardopts +7 -0
- data/Berksfile +28 -0
- data/Gemfile +33 -0
- data/LICENSE +201 -0
- data/README.md +399 -0
- data/Rakefile +17 -0
- data/chef/attributes/default.rb +24 -0
- data/chef/recipes/default.rb +20 -0
- data/lib/poise_python.rb +25 -0
- data/lib/poise_python/cheftie.rb +18 -0
- data/lib/poise_python/error.rb +23 -0
- data/lib/poise_python/python_command_mixin.rb +45 -0
- data/lib/poise_python/python_providers.rb +35 -0
- data/lib/poise_python/python_providers/base.rb +177 -0
- data/lib/poise_python/python_providers/portable_pypy.rb +96 -0
- data/lib/poise_python/python_providers/scl.rb +77 -0
- data/lib/poise_python/python_providers/system.rb +86 -0
- data/lib/poise_python/resources.rb +31 -0
- data/lib/poise_python/resources/pip_requirements.rb +102 -0
- data/lib/poise_python/resources/python_execute.rb +83 -0
- data/lib/poise_python/resources/python_package.rb +322 -0
- data/lib/poise_python/resources/python_runtime.rb +114 -0
- data/lib/poise_python/resources/python_runtime_pip.rb +167 -0
- data/lib/poise_python/resources/python_runtime_test.rb +185 -0
- data/lib/poise_python/resources/python_virtualenv.rb +164 -0
- data/lib/poise_python/utils.rb +63 -0
- data/lib/poise_python/utils/python_encoder.rb +73 -0
- data/lib/poise_python/version.rb +20 -0
- data/poise-python.gemspec +41 -0
- data/test/cookbooks/poise-python_test/metadata.rb +18 -0
- data/test/cookbooks/poise-python_test/recipes/default.rb +40 -0
- data/test/gemfiles/chef-12.gemfile +19 -0
- data/test/gemfiles/master.gemfile +23 -0
- data/test/integration/default/serverspec/default_spec.rb +102 -0
- data/test/spec/python_command_mixin_spec.rb +115 -0
- data/test/spec/python_providers/portable_pypy_spec.rb +68 -0
- data/test/spec/python_providers/scl_spec.rb +75 -0
- data/test/spec/python_providers/system_spec.rb +81 -0
- data/test/spec/resources/pip_requirements_spec.rb +69 -0
- data/test/spec/resources/python_package_spec.rb +65 -0
- data/test/spec/resources/python_runtime_pip_spec.rb +33 -0
- data/test/spec/resources/python_virtualenv_spec.rb +103 -0
- data/test/spec/spec_helper.rb +19 -0
- data/test/spec/utils/python_encoder_spec.rb +79 -0
- data/test/spec/utils_spec.rb +86 -0
- metadata +170 -0
@@ -0,0 +1,114 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2015, Noah Kantrowitz
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'chef/resource'
|
18
|
+
require 'poise'
|
19
|
+
|
20
|
+
|
21
|
+
module PoisePython
|
22
|
+
module Resources
|
23
|
+
# (see PythonRuntime::Resource)
|
24
|
+
# @since 1.0.0
|
25
|
+
module PythonRuntime
|
26
|
+
# A `python_runtime` resource to manage Python installations.
|
27
|
+
#
|
28
|
+
# @provides python_runtime
|
29
|
+
# @action install
|
30
|
+
# @action uninstall
|
31
|
+
# @example
|
32
|
+
# python_runtime '2.7'
|
33
|
+
class Resource < Chef::Resource
|
34
|
+
include Poise(inversion: true, container: true)
|
35
|
+
provides(:python_runtime)
|
36
|
+
actions(:install, :uninstall)
|
37
|
+
|
38
|
+
# @!attribute version
|
39
|
+
# Version of Python to install. The version is prefix-matched so `'2'`
|
40
|
+
# will install the most recent Python 2.x, and so on.
|
41
|
+
# @return [String]
|
42
|
+
# @example Install any version
|
43
|
+
# python_runtime 'any' do
|
44
|
+
# version ''
|
45
|
+
# end
|
46
|
+
# @example Install Python 2.7
|
47
|
+
# python_runtime '2.7'
|
48
|
+
attribute(:version, kind_of: String, name_attribute: true)
|
49
|
+
# @!attribute pip_version
|
50
|
+
# Version of pip to install. If set to `true`, the latest available
|
51
|
+
# pip will be used. If set to `false`, pip will not be installed. If
|
52
|
+
# set to a URL, that will be used as the URL to get-pip.py. If a
|
53
|
+
# non-URL version is given, the get-pip.py installer will be
|
54
|
+
# downloaded from the internet.
|
55
|
+
# @note Due to https://github.com/pypa/pip/issues/1087, the latest
|
56
|
+
# version of pip will always be installed initially. It will then
|
57
|
+
# downgrade to the requested version if needed.
|
58
|
+
# @note Disabling the pip install may result in other resources being
|
59
|
+
# non-functional.
|
60
|
+
# @return [String, Boolean]
|
61
|
+
# @example Install from a locally-hosted copy of get-pip.py
|
62
|
+
# python_runtime '2' do
|
63
|
+
# pip_version 'http://myserver/get-pip.py'
|
64
|
+
# end
|
65
|
+
attribute(:pip_version, kind_of: [String, TrueClass, FalseClass], default: true)
|
66
|
+
# @!attribute setuptools_version
|
67
|
+
# Version of Setuptools to install. It set to `true`, the latest
|
68
|
+
# available version will be used. If set to `false`, setuptools will
|
69
|
+
# not be installed.
|
70
|
+
# @return [String, Boolean]
|
71
|
+
attribute(:setuptools_version, kind_of: [String, TrueClass, FalseClass], default: true)
|
72
|
+
# @!attribute virtualenv_version
|
73
|
+
# Version of Virtualenv to install. It set to `true`, the latest
|
74
|
+
# available version will be used. If set to `false`, virtualenv will
|
75
|
+
# not be installed. Virtualenv will never be installed if the built-in
|
76
|
+
# venv module is available.
|
77
|
+
# @note Disabling the virtualenv install may result in other resources
|
78
|
+
# being non-functional.
|
79
|
+
# @return [String, Boolean]
|
80
|
+
attribute(:virtualenv_version, kind_of: [String, TrueClass, FalseClass], default: true)
|
81
|
+
# @!attribute wheel_version
|
82
|
+
# Version of Wheel to install. It set to `true`, the latest
|
83
|
+
# available version will be used. If set to `false`, wheel will not
|
84
|
+
# be installed.
|
85
|
+
# @return [String, Boolean]
|
86
|
+
attribute(:wheel_version, kind_of: [String, TrueClass, FalseClass], default: true)
|
87
|
+
|
88
|
+
# The path to the `python` binary for this Python installation. This is
|
89
|
+
# an output property.
|
90
|
+
#
|
91
|
+
# @return [String]
|
92
|
+
# @example
|
93
|
+
# execute "#{resources('python_runtime[2.7]').python_binary} myapp.py"
|
94
|
+
def python_binary
|
95
|
+
provider_for_action(:python_binary).python_binary
|
96
|
+
end
|
97
|
+
|
98
|
+
# The environment variables for this Python installation. This is an
|
99
|
+
# output property.
|
100
|
+
#
|
101
|
+
# @return [Hash<String, String>]
|
102
|
+
# @example
|
103
|
+
# execute '/opt/myapp.py' do
|
104
|
+
# environment resources('python_runtime[2.7]').python_environment
|
105
|
+
# end
|
106
|
+
def python_environment
|
107
|
+
provider_for_action(:python_environment).python_environment
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Providers can be found under lib/poise_python/python_providers/
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2015, Noah Kantrowitz
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'tempfile'
|
18
|
+
|
19
|
+
require 'chef/resource'
|
20
|
+
require 'poise'
|
21
|
+
|
22
|
+
|
23
|
+
module PoisePython
|
24
|
+
module Resources
|
25
|
+
# (see PythonRuntimePip::Resource)
|
26
|
+
# @since 1.0.0
|
27
|
+
# @api private
|
28
|
+
module PythonRuntimePip
|
29
|
+
# URL for the default get-pip.py script.
|
30
|
+
DEFAULT_GET_PIP_URL = 'https://bootstrap.pypa.io/get-pip.py'
|
31
|
+
|
32
|
+
# A `python_runtime_pip` resource to install/upgrade pip itself. This is
|
33
|
+
# used internally by `python_runtime` and is not intended to be a public
|
34
|
+
# API.
|
35
|
+
#
|
36
|
+
# @provides python_runtime_pip
|
37
|
+
# @action install
|
38
|
+
# @action uninstall
|
39
|
+
class Resource < Chef::Resource
|
40
|
+
include Poise(parent: :python_runtime)
|
41
|
+
provides(:python_runtime_pip)
|
42
|
+
actions(:install, :uninstall)
|
43
|
+
|
44
|
+
# @!attribute version
|
45
|
+
# Version of pip to install. Only kind of works due to
|
46
|
+
# https://github.com/pypa/pip/issues/1087.
|
47
|
+
# @return [String]
|
48
|
+
attribute(:version, kind_of: String)
|
49
|
+
# @!attribute get_pip_url
|
50
|
+
# URL to the get-pip.py script. Defaults to pulling it from pypa.io.
|
51
|
+
# @return [String]
|
52
|
+
attribute(:get_pip_url, kind_of: String, default: DEFAULT_GET_PIP_URL)
|
53
|
+
end
|
54
|
+
|
55
|
+
# The default provider for `python_runtime_pip`.
|
56
|
+
#
|
57
|
+
# @see Resource
|
58
|
+
# @provides python_runtime_pip
|
59
|
+
class Provider < Chef::Provider
|
60
|
+
include Poise
|
61
|
+
provides(:python_runtime_pip)
|
62
|
+
|
63
|
+
# @api private
|
64
|
+
def load_current_resource
|
65
|
+
super.tap do |current_resource|
|
66
|
+
# Try to find the current version if possible.
|
67
|
+
current_resource.version(pip_version)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# The `install` action for the `python_runtime_pip` resource.
|
72
|
+
#
|
73
|
+
# @return [void]
|
74
|
+
def action_install
|
75
|
+
if current_resource.version
|
76
|
+
install_pip
|
77
|
+
else
|
78
|
+
bootstrap_pip
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# The `uninstall` action for the `python_runtime_pip` resource.
|
83
|
+
#
|
84
|
+
# @return [void]
|
85
|
+
def action_uninstall
|
86
|
+
notifying_block do
|
87
|
+
python_package 'pip' do
|
88
|
+
action :uninstall
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
# Bootstrap pip using get-pip.py.
|
96
|
+
#
|
97
|
+
# @return [void]
|
98
|
+
def bootstrap_pip
|
99
|
+
# Always updated if we have hit this point.
|
100
|
+
new_resource.updated_by_last_action(true)
|
101
|
+
# Pending https://github.com/pypa/pip/issues/1087.
|
102
|
+
if new_resource.version
|
103
|
+
Chef::Log.warn("pip does not support bootstrapping a specific version, see https://github.com/pypa/pip/issues/1087.")
|
104
|
+
end
|
105
|
+
# Use a temp file to hold the installer.
|
106
|
+
Tempfile.create(['get-pip', '.py']) do |temp|
|
107
|
+
# Download the get-pip.py.
|
108
|
+
get_pip = Chef::HTTP.new(new_resource.get_pip_url).get('')
|
109
|
+
# Write it to the temp file.
|
110
|
+
temp.write(get_pip)
|
111
|
+
# Close the file to flush it.
|
112
|
+
temp.close
|
113
|
+
# Run the install. This probably needs some handling for proxies et
|
114
|
+
# al. Disable setuptools and wheel as we will install those later.
|
115
|
+
# Use the environment vars instead of CLI arguments so I don't have
|
116
|
+
# to deal with bootstrap versions that don't support --no-wheel.
|
117
|
+
shell_out!([new_resource.parent.python_binary, temp.path], environment: new_resource.parent.python_environment.merge('PIP_NO_SETUPTOOLS' => '1', 'PIP_NO_WHEEL' => '1'))
|
118
|
+
end
|
119
|
+
new_pip_version = pip_version
|
120
|
+
if new_resource.version && new_pip_version != new_resource.version
|
121
|
+
# We probably want to downgrade, which is silly but ¯\_(ツ)_/¯.
|
122
|
+
# Can be removed once https://github.com/pypa/pip/issues/1087 is fixed.
|
123
|
+
current_resource.version(new_pip_version)
|
124
|
+
install_pip
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Upgrade (or downgrade) pip using itself. Should work back at least
|
129
|
+
# pip 1.5.
|
130
|
+
#
|
131
|
+
# @return [void]
|
132
|
+
def install_pip
|
133
|
+
if new_resource.version
|
134
|
+
# Already up to date, we're done here.
|
135
|
+
return if current_resource.version == new_resource.version
|
136
|
+
else
|
137
|
+
# We don't wany a specific version, so just make a general check.
|
138
|
+
return if current_resource.version
|
139
|
+
end
|
140
|
+
|
141
|
+
notifying_block do
|
142
|
+
# Use pip to upgrade (or downgrade) itself.
|
143
|
+
python_package 'pip' do
|
144
|
+
action :upgrade
|
145
|
+
version new_resource.version if new_resource.version
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Find the version of pip currently installed in this Python runtime.
|
151
|
+
# Returns nil if not installed.
|
152
|
+
#
|
153
|
+
# @return [String, nil]
|
154
|
+
def pip_version
|
155
|
+
cmd = shell_out([new_resource.parent.python_binary, '-m', 'pip.__main__', '--version'], environment: new_resource.parent.python_environment)
|
156
|
+
if cmd.error?
|
157
|
+
# Not installed, probably.
|
158
|
+
nil
|
159
|
+
else
|
160
|
+
cmd.stdout[/pip ([\d.a-z]+)/, 1]
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2015, Noah Kantrowitz
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'chef/provider'
|
18
|
+
require 'chef/resource'
|
19
|
+
require 'poise'
|
20
|
+
|
21
|
+
|
22
|
+
module PoisePython
|
23
|
+
module Resources
|
24
|
+
# (see PythonRuntimeTest::Resource)
|
25
|
+
# @since 1.0.0
|
26
|
+
# @api private
|
27
|
+
module PythonRuntimeTest
|
28
|
+
# A `python_runtime_test` resource for integration testing of this
|
29
|
+
# cookbook. This is an internal API and can change at any time.
|
30
|
+
#
|
31
|
+
# @provides python_runtime_test
|
32
|
+
# @action run
|
33
|
+
class Resource < Chef::Resource
|
34
|
+
include Poise
|
35
|
+
provides(:python_runtime_test)
|
36
|
+
actions(:run)
|
37
|
+
|
38
|
+
attribute(:version, kind_of: String, name_attribute: true)
|
39
|
+
attribute(:runtime_provider, kind_of: Symbol)
|
40
|
+
attribute(:path, kind_of: String, default: lazy { default_path })
|
41
|
+
|
42
|
+
def default_path
|
43
|
+
::File.join('', 'root', "python_test_#{name}")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# The default provider for `python_runtime_test`.
|
48
|
+
#
|
49
|
+
# @see Resource
|
50
|
+
# @provides python_runtime_test
|
51
|
+
class Provider < Chef::Provider
|
52
|
+
include Poise
|
53
|
+
provides(:python_runtime_test)
|
54
|
+
|
55
|
+
# The `run` action for the `python_runtime_test` resource.
|
56
|
+
#
|
57
|
+
# @return [void]
|
58
|
+
def action_run
|
59
|
+
notifying_block do
|
60
|
+
# Top level directory for this test.
|
61
|
+
directory new_resource.path
|
62
|
+
|
63
|
+
# Install and log the version.
|
64
|
+
python_runtime new_resource.name do
|
65
|
+
provider new_resource.runtime_provider if new_resource.runtime_provider
|
66
|
+
version new_resource.version
|
67
|
+
end
|
68
|
+
test_version
|
69
|
+
|
70
|
+
# Test python_package.
|
71
|
+
python_package 'sqlparse remove before' do
|
72
|
+
action :remove
|
73
|
+
package_name 'sqlparse'
|
74
|
+
python new_resource.name
|
75
|
+
end
|
76
|
+
test_import('sqlparse', 'sqlparse_before')
|
77
|
+
python_package 'sqlparse' do
|
78
|
+
python new_resource.name
|
79
|
+
notifies :create, sentinel_file('sqlparse'), :immediately
|
80
|
+
end
|
81
|
+
test_import('sqlparse', 'sqlparse_mid')
|
82
|
+
python_package 'sqlparse again' do
|
83
|
+
package_name 'sqlparse'
|
84
|
+
python new_resource.name
|
85
|
+
notifies :create, sentinel_file('sqlparse2'), :immediately
|
86
|
+
end
|
87
|
+
python_package 'sqlparse remove after' do
|
88
|
+
action :remove
|
89
|
+
package_name 'sqlparse'
|
90
|
+
python new_resource.name
|
91
|
+
end
|
92
|
+
test_import('sqlparse', 'sqlparse_after')
|
93
|
+
|
94
|
+
# Use setuptools to test something that should always be installed.
|
95
|
+
python_package 'setuptools' do
|
96
|
+
python new_resource.name
|
97
|
+
notifies :create, sentinel_file('setuptools'), :immediately
|
98
|
+
end
|
99
|
+
|
100
|
+
# Multi-package install.
|
101
|
+
python_package ['pep8', 'pytz'] do
|
102
|
+
python new_resource.name
|
103
|
+
end
|
104
|
+
test_import('pep8')
|
105
|
+
test_import('pytz')
|
106
|
+
|
107
|
+
# Create a virtualenv.
|
108
|
+
python_virtualenv ::File.join(new_resource.path, 'venv') do
|
109
|
+
python new_resource.name
|
110
|
+
end
|
111
|
+
|
112
|
+
# Install a package inside a virtualenv.
|
113
|
+
python_package 'Pytest' do
|
114
|
+
virtualenv ::File.join(new_resource.path, 'venv')
|
115
|
+
end
|
116
|
+
test_import('pytest')
|
117
|
+
test_import('pytest', 'pytest_venv', python: nil, virtualenv: ::File.join(new_resource.path, 'venv'))
|
118
|
+
|
119
|
+
# Create and install a requirements file.
|
120
|
+
file ::File.join(new_resource.path, 'requirements.txt') do
|
121
|
+
content <<-EOH
|
122
|
+
requests==2.7.0
|
123
|
+
six==1.8.0
|
124
|
+
EOH
|
125
|
+
end
|
126
|
+
pip_requirements ::File.join(new_resource.path, 'requirements.txt') do
|
127
|
+
python new_resource.name
|
128
|
+
end
|
129
|
+
test_import('requests')
|
130
|
+
test_import('six')
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def sentinel_file(name)
|
135
|
+
file ::File.join(new_resource.path, "sentinel_#{name}") do
|
136
|
+
action :nothing
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
def test_version(python: new_resource.name, virtualenv: nil)
|
143
|
+
# Only queue up this resource once, the ivar is just for tracking.
|
144
|
+
@python_version_test ||= file ::File.join(new_resource.path, 'python_version.py') do
|
145
|
+
user 'root'
|
146
|
+
group 'root'
|
147
|
+
mode '644'
|
148
|
+
content <<-EOH
|
149
|
+
import sys, platform
|
150
|
+
open(sys.argv[1], 'w').write(platform.python_version())
|
151
|
+
EOH
|
152
|
+
end
|
153
|
+
|
154
|
+
python_execute "#{@python_version_test.path} #{::File.join(new_resource.path, 'version')}" do
|
155
|
+
python python if python
|
156
|
+
virtualenv virtualenv if virtualenv
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def test_import(name, path=name, python: new_resource.name, virtualenv: nil)
|
161
|
+
# Only queue up this resource once, the ivar is just for tracking.
|
162
|
+
@python_import_test ||= file ::File.join(new_resource.path, 'import_version.py') do
|
163
|
+
user 'root'
|
164
|
+
group 'root'
|
165
|
+
mode '644'
|
166
|
+
content <<-EOH
|
167
|
+
try:
|
168
|
+
import sys
|
169
|
+
mod = __import__(sys.argv[1])
|
170
|
+
open(sys.argv[2], 'w').write(mod.__version__)
|
171
|
+
except ImportError:
|
172
|
+
pass
|
173
|
+
EOH
|
174
|
+
end
|
175
|
+
|
176
|
+
python_execute "#{@python_import_test.path} #{name} #{::File.join(new_resource.path, "import_#{path}")}" do
|
177
|
+
python python if python
|
178
|
+
virtualenv virtualenv if virtualenv
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|