simple_scripting 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,87 @@
1
+ [![Build Status][BS img]](https://travis-ci.org/saveriomiroddi/simple_scripting)
2
+
3
+ # SimpleScripting
4
+
5
+ `SS` is a library composed of two modules (`Argv` and `Configuration`) which simplify two common scripting tasks:
6
+
7
+ - implementing the commandline options parsing (and the related help)
8
+ - loading and decoding the configuration for the script/application
9
+
10
+ `SS` is an interesting (and useful) exercise in design, aimed at finding the simplest and most expressive data/structures which accomplish the given task(s). For this reason, the library can be useful for people who frequently write small scripts (eg. devops or nerds).
11
+
12
+ ## SimpleScripting::Argv
13
+
14
+ `SS::A` is a module which acts as frontend to the standard Option Parser library (`optparse`), giving a very convenient format for specifying the arguments. `SS::A` also generates the help.
15
+
16
+ This is a definition example:
17
+
18
+ result = SimpleOptParse::Argv.decode(
19
+ ['-s', '--only-scheduled-days', 'Only print scheduled days' ],
20
+ ['-d', '--print-defaults TEMPLATE', 'Print the default activities from the named template'],
21
+ 'schedule',
22
+ '[weeks]',
23
+ long_help: 'This is the long help! It can span multiple lines.'
24
+ )
25
+
26
+ which:
27
+
28
+ - optionally accepts the `-s`/`--only-scheduled-days` switch, interpreting it as boolean,
29
+ - optionally accepts the `-d`/`--print-defaults` switch, interpreting it as string,
30
+ - requires the `schedule` argument,
31
+ - optionally accepts the `weeks` argument,
32
+ - automatically adds the `-h` and `--help` switches,
33
+ - prints all the options and the long help if the help is invoked,
34
+ - prints the help and exits if invalid parameters are passed (eg. too many).
35
+
36
+ This is a sample result:
37
+
38
+ {
39
+ only_scheduled_days: true,
40
+ print_defaults: 'my_defaults',
41
+ schedule: 'schedule.txt',
42
+ weeks: '3',
43
+ }
44
+
45
+ This is the corresponding help:
46
+
47
+ Usage: tmpfile [options] <schedule> [<weeks>]
48
+ -s, --only-scheduled-days Only print scheduled days
49
+ -d, --print-defaults TEMPLATE Print the default activities from the named template
50
+ -h, --help Help
51
+
52
+ This is the long help! It can span multiple lines.
53
+
54
+ For the guide, see the [wiki page](https://github.com/saveriomiroddi/simple_scripting/wiki/SimpleScripting::Argv-Guide).
55
+
56
+ ## SimpleScripting::Configuration
57
+
58
+ `SS::C` is a module which acts as frontend to the ParseConfig gem (`parseconfig`), giving compact access to the configuration and its values, and adding a few helpers for common tasks.
59
+
60
+ Say one writes a script (`foo_my_bar.rb`), with a corresponding (`$HOME/.foo_my_bar`) configuration, which contains:
61
+
62
+ some_relative_file_path=foo
63
+ some_absolute_file_path=/path/to/bar
64
+ my_password=uTxllKRD2S+IH92oi30luwu0JIqp7kKA
65
+
66
+ [a_group]
67
+ group_key=baz
68
+
69
+ This is the workflow and functionality offered by `SS::C`:
70
+
71
+ # Picks up automatically the configuration file name, based on the calling program
72
+ #
73
+ configuration = SimpleScripting::Configuration.load(passwords_key: 'encryption_key')
74
+
75
+ configuration.some_relative_file_path.full_path # '$HOME/foo'
76
+ configuration.some_absolute_file_path # '/path/to/bar'
77
+ configuration.some_absolute_file_path.full_path # '/path/to/bar' (recognized as absolute)
78
+
79
+ configuration.my_password.decrypted # 'encrypted_value'
80
+
81
+ configuration.a_group.group_key # 'baz'; also supports #full_path and #decrypted
82
+
83
+ ### Encryption note
84
+
85
+ The purpose of encryption in this library is just to avoid displaying passwords in plaintext; it's not considered safe against attacks.
86
+
87
+ [BS img]: https://travis-ci.org/saveriomiroddi/simple_scripting.svg?branch=master
@@ -0,0 +1,5 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ RSpec::Core::RakeTask.new(:spec)
4
+
5
+ task default: :spec
@@ -0,0 +1,126 @@
1
+ require 'optparse'
2
+
3
+ module SimpleScripting
4
+
5
+ module Argv
6
+
7
+ extend self
8
+
9
+ def decode(*params_definition, arguments: ARGV, long_help: nil, output: $stdout)
10
+ # If the param is a Hash, we have multiple commands. We check and if the command is correct,
11
+ # recursively call the function with the specific parameters.
12
+ #
13
+ if params_definition.first.is_a?(Hash)
14
+ command = arguments.shift
15
+ commands_definition = params_definition.first
16
+
17
+ if command == '-h' || command == '--help'
18
+ print_optparse_commands_help(commands_definition, output, false)
19
+ output == $stdout ? exit : return
20
+ end
21
+
22
+ command_params_definition = commands_definition[command]
23
+
24
+ if command_params_definition.nil?
25
+ print_optparse_commands_help(commands_definition, output, true)
26
+ output == $stdout ? exit : return
27
+ else
28
+ return [command, decode(*command_params_definition, arguments: arguments, output: output)]
29
+ end
30
+ end
31
+
32
+ result = {}
33
+ parser_opts_ref = nil # not available outside the block
34
+ args = {} # { 'name' => mandatory? }
35
+
36
+ OptionParser.new do | parser_opts |
37
+ params_definition.each do | param_definition |
38
+ case param_definition
39
+ when Array
40
+ if param_definition[1] && param_definition[1].start_with?('--')
41
+ key = param_definition[1].split(' ')[0][2 .. -1].gsub('-', '_').to_sym
42
+ else
43
+ key = param_definition[0][1 .. -1].to_sym
44
+ end
45
+
46
+ parser_opts.on(*param_definition) do |value|
47
+ result[key] = value || true
48
+ end
49
+ when String
50
+ if param_definition.start_with?('[')
51
+ arg_name = param_definition[1 .. -2].to_sym
52
+
53
+ args[arg_name] = false
54
+ else
55
+ arg_name = param_definition.to_sym
56
+
57
+ args[arg_name] = true
58
+ end
59
+ else
60
+ raise "Unrecognized value: #{param_definition}"
61
+ end
62
+ end
63
+
64
+ parser_opts.on( '-h', '--help', 'Help' ) do
65
+ print_optparse_help( parser_opts, args, long_help, output )
66
+ output == $stdout ? exit : return
67
+ end
68
+
69
+ parser_opts_ref = parser_opts
70
+ end.parse!(arguments)
71
+
72
+ first_arg_name = args.keys.first.to_s
73
+
74
+ # Varargs
75
+ if first_arg_name.start_with?('*')
76
+ # Mandatory?
77
+ if args.fetch(first_arg_name.to_sym)
78
+ if arguments.empty?
79
+ print_optparse_help( parser_opts_ref, args, long_help, output )
80
+ output == $stdout ? exit : return
81
+ else
82
+ name = args.keys.first[ 1 .. - 1 ].to_sym
83
+
84
+ result[ name ] = arguments
85
+ end
86
+ # Optional
87
+ else
88
+ name = args.keys.first[ 1 .. - 1 ].to_sym
89
+
90
+ result[ name ] = arguments
91
+ end
92
+ else
93
+ min_args_size = args.count { | name, mandatory | mandatory }
94
+
95
+ case arguments.size
96
+ when (min_args_size .. args.size)
97
+ arguments.zip(args) do | value, (name, mandatory) |
98
+ result[name] = value
99
+ end
100
+ else
101
+ print_optparse_help(parser_opts_ref, args, long_help, output)
102
+ output == $stdout ? exit : return
103
+ end
104
+ end
105
+
106
+ result
107
+ end
108
+
109
+ private
110
+
111
+ def print_optparse_commands_help(commands_definition, output, is_error)
112
+ output.print "Invalid command. " if is_error
113
+ output.puts "Valid commands:", "", " " + commands_definition.keys.join(', ')
114
+ end
115
+
116
+ def print_optparse_help(parser_opts, args, long_help, output)
117
+ args_display = args.map { | name, mandatory | mandatory ? "<#{ name }>" : "[<#{ name }>]" }.join(' ')
118
+ parser_opts_help = parser_opts.to_s.sub!(/^(Usage: .*)/, "\\1 #{args_display}")
119
+
120
+ output.puts parser_opts_help
121
+ output.puts "", long_help if long_help
122
+ end
123
+
124
+ end
125
+
126
+ end
@@ -0,0 +1,46 @@
1
+ require_relative 'configuration/value'
2
+
3
+ require 'ostruct'
4
+ require 'parseconfig'
5
+
6
+ module SimpleScripting
7
+
8
+ module Configuration
9
+
10
+ extend self
11
+
12
+ def load(config_file: default_config_file, passwords_key: nil)
13
+ configuration = ParseConfig.new(config_file)
14
+
15
+ convert_to_cool_format(OpenStruct.new, configuration.params, passwords_key)
16
+ end
17
+
18
+ private
19
+
20
+ def default_config_file
21
+ base_config_filename = '.' + File.basename($PROGRAM_NAME).chomp('.rb')
22
+
23
+ File.expand_path(base_config_filename, '~')
24
+ end
25
+
26
+ # Performs two conversions:
27
+ #
28
+ # 1. the configuration as a whole is converted to an OpenStruct
29
+ # 2. the values are converted to SimpleScripting::Configuration::Value
30
+ #
31
+ def convert_to_cool_format(result_node, configuration_node, encryption_key)
32
+ configuration_node.each do |key, value|
33
+ if value.is_a?(Hash)
34
+ result_node[key] = OpenStruct.new
35
+ convert_to_cool_format(result_node[key], value, encryption_key)
36
+ else
37
+ result_node[key] = Value.new(value, encryption_key)
38
+ end
39
+ end
40
+
41
+ result_node
42
+ end
43
+
44
+ end
45
+
46
+ end
@@ -0,0 +1,61 @@
1
+ require 'openssl'
2
+ require 'base64'
3
+
4
+ module SimpleScripting
5
+
6
+ module Configuration
7
+
8
+ # The purpose of encryption in this library is just to avoid displaying passwords in
9
+ # plaintext; it's not considered safe against attacks.
10
+ #
11
+ class Value < String
12
+
13
+ ENCRYPTION_CIPHER = 'des3'
14
+
15
+ def initialize(string, encryption_key = nil)
16
+ super(string)
17
+
18
+ if encryption_key
19
+ @encryption_key = encryption_key + '*' * (24 - encryption_key.bytesize)
20
+ end
21
+ end
22
+
23
+ def full_path
24
+ start_with?('/') ? self : File.expand_path(self, '~')
25
+ end
26
+
27
+ def decrypted
28
+ raise "Encryption key not provided!" if @encryption_key.nil?
29
+
30
+ ciphertext = Base64.decode64(self)
31
+
32
+ cipher = OpenSSL::Cipher::Cipher.new(ENCRYPTION_CIPHER)
33
+ cipher.decrypt
34
+
35
+ cipher.key = @encryption_key
36
+
37
+ cipher.iv = ciphertext[0...cipher.iv_len]
38
+ plaintext = cipher.update(ciphertext[cipher.iv_len..-1]) + cipher.final
39
+
40
+ plaintext
41
+ end
42
+
43
+ def encrypted
44
+ cipher = OpenSSL::Cipher::Cipher.new(ENCRYPTION_CIPHER)
45
+ cipher.encrypt
46
+
47
+ iv = cipher.random_iv
48
+
49
+ cipher.key = @encryption_key
50
+ cipher.iv = iv
51
+
52
+ ciphertext = iv + cipher.update(self) + cipher.final
53
+
54
+ Base64.encode64(ciphertext).rstrip
55
+ end
56
+
57
+ end
58
+
59
+ end
60
+
61
+ end
@@ -0,0 +1,5 @@
1
+ module SimpleScripting
2
+
3
+ VERSION = "0.9.0"
4
+
5
+ end
@@ -0,0 +1,28 @@
1
+ # encoding: UTF-8
2
+
3
+ $LOAD_PATH << File.expand_path("../lib", __FILE__)
4
+
5
+ require "simple_scripting/version"
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "simple_scripting"
9
+ s.version = SimpleScripting::VERSION
10
+ s.platform = Gem::Platform::RUBY
11
+ s.authors = ["Saverio Miroddi"]
12
+ s.date = "2017-06-27"
13
+ s.email = ["saverio.pub2@gmail.com"]
14
+ s.homepage = "https://github.com/saveriomiroddi/simple_scripting"
15
+ s.summary = "Library for simplifying some typical scripting functionalities."
16
+ s.description = "Simplifies options parsing and configuration loading."
17
+ s.license = "GPL-3.0"
18
+
19
+ s.add_runtime_dependency "parseconfig", "~> 1.0"
20
+
21
+ s.add_development_dependency "rake", "~> 12.0"
22
+ s.add_development_dependency "rspec", "~> 3.6"
23
+
24
+ s.files = `git ls-files`.split("\n")
25
+ s.test_files = `git ls-files -- spec/*`.split("\n")
26
+ s.executables = []
27
+ s.require_paths = ["lib"]
28
+ end
@@ -0,0 +1,222 @@
1
+ require_relative '../../lib/simple_scripting/argv.rb'
2
+
3
+ require 'stringio'
4
+
5
+ describe SimpleScripting::Argv do
6
+
7
+ let(:output_buffer) do
8
+ StringIO.new
9
+ end
10
+
11
+ describe 'Basic functionality' do
12
+
13
+ let(:decoder_params) {[
14
+ ['-a' ],
15
+ ['-b', '"-b" description'],
16
+ ['-c', '--c-switch' ],
17
+ ['-d', '--d-switch', '"-d" description'],
18
+ ['-e', '--e-switch VALUE' ],
19
+ ['-f', '--f-switch VALUE', '"-f" description'],
20
+ 'mandatory',
21
+ '[optional]',
22
+ long_help: 'This is the long help!',
23
+ output: output_buffer,
24
+ ]}
25
+
26
+ it 'should implement the help' do
27
+ decoder_params.last[:arguments] = ['-h']
28
+
29
+ described_class.decode(*decoder_params)
30
+
31
+ expected_output = %Q{\
32
+ Usage: rspec [options] <mandatory> [<optional>]
33
+ -a
34
+ -b "-b" description
35
+ -c, --c-switch
36
+ -d, --d-switch "-d" description
37
+ -e, --e-switch VALUE
38
+ -f, --f-switch VALUE "-f" description
39
+ -h, --help Help
40
+
41
+ This is the long help!
42
+ }
43
+
44
+ expect(output_buffer.string).to eql(expected_output)
45
+ end
46
+
47
+ it "should implement basic switches and arguments (all set)" do
48
+ decoder_params.last[:arguments] = ['-a', '-b', '-c', '-d', '-ev_swt', '-fv_swt', 'm_arg', 'o_arg']
49
+
50
+ actual_result = described_class.decode(*decoder_params)
51
+
52
+ expected_result = {
53
+ a: true,
54
+ b: true,
55
+ c_switch: true,
56
+ d_switch: true,
57
+ e_switch: 'v_swt',
58
+ f_switch: 'v_swt',
59
+ mandatory: 'm_arg',
60
+ optional: 'o_arg',
61
+ }
62
+
63
+ expect(actual_result).to eql(expected_result)
64
+ end
65
+
66
+ it "should implement basic switches and arguments (no optional argument)" do
67
+ decoder_params.last[:arguments] = ['m_arg']
68
+
69
+ actual_result = described_class.decode(*decoder_params)
70
+
71
+ expected_result = {
72
+ mandatory: 'm_arg',
73
+ }
74
+
75
+ expect(actual_result).to eql(expected_result)
76
+ end
77
+
78
+ end
79
+
80
+ describe 'Varargs' do
81
+
82
+ describe '(mandatory)' do
83
+
84
+ let(:decoder_params) {[
85
+ '*varargs',
86
+ output: output_buffer,
87
+ ]}
88
+
89
+ it "should be decoded" do
90
+ decoder_params.last[:arguments] = ['varval1', 'varval2']
91
+
92
+ actual_result = described_class.decode(*decoder_params)
93
+
94
+ expected_result = {
95
+ varargs: ['varval1', 'varval2'],
96
+ }
97
+
98
+ expect(actual_result).to eql(expected_result)
99
+ end
100
+
101
+ it "should exit when they are not specified" do
102
+ decoder_params.last[:arguments] = []
103
+
104
+ actual_result = described_class.decode(*decoder_params)
105
+
106
+ expected_result = nil
107
+
108
+ expect(actual_result).to eql(expected_result)
109
+ end
110
+
111
+ end
112
+
113
+ describe '(optional)' do
114
+
115
+ let(:decoder_params) {[
116
+ '[*varargs]',
117
+ output: output_buffer,
118
+ ]}
119
+
120
+ it "should be decoded" do
121
+ decoder_params.last[:arguments] = ['varval1', 'varval2']
122
+
123
+ actual_result = described_class.decode(*decoder_params)
124
+
125
+ expected_result = {
126
+ varargs: ['varval1', 'varval2'],
127
+ }
128
+
129
+ expect(actual_result).to eql(expected_result)
130
+ end
131
+
132
+ it "should be allowed not to be specified" do
133
+ decoder_params.last[:arguments] = []
134
+
135
+ actual_result = described_class.decode(*decoder_params)
136
+
137
+ expected_result = {
138
+ varargs: [],
139
+ }
140
+
141
+ expect(actual_result).to eql(expected_result)
142
+ end
143
+
144
+ end
145
+
146
+ end
147
+
148
+ describe 'Multiple commands' do
149
+
150
+ describe 'regular case' do
151
+
152
+ let(:decoder_params) {{
153
+ 'command1' => [
154
+ 'arg1'
155
+ ],
156
+ 'command2' => [
157
+ 'arg2'
158
+ ],
159
+ output: output_buffer,
160
+ }}
161
+
162
+ it 'should be decoded' do
163
+ decoder_params[:arguments] = ['command1', 'value1']
164
+
165
+ actual_result = described_class.decode(decoder_params)
166
+
167
+ expected_result = ['command1', arg1: 'value1']
168
+
169
+ expect(actual_result).to eql(expected_result)
170
+ end
171
+
172
+ it 'print a message on wrong command' do
173
+ decoder_params[:arguments] = ['pizza']
174
+
175
+ described_class.decode(decoder_params)
176
+
177
+ expected_output = %Q{\
178
+ Invalid command. Valid commands:
179
+
180
+ command1, command2
181
+ }
182
+
183
+ expect(output_buffer.string).to eql(expected_output)
184
+ end
185
+
186
+ it 'should implement the help' do
187
+ decoder_params[:arguments] = ['-h']
188
+
189
+ described_class.decode(decoder_params)
190
+
191
+ expected_output = %Q{\
192
+ Valid commands:
193
+
194
+ command1, command2
195
+ }
196
+
197
+ expect(output_buffer.string).to eql(expected_output)
198
+ end
199
+
200
+ end
201
+
202
+ describe 'pitfall' do
203
+
204
+ let(:decoder_params) {{
205
+ output: output_buffer,
206
+ }}
207
+
208
+ # Make sure that the options (in this case, :output) are not interpreted as commands definition.
209
+ #
210
+ it 'should be avoided' do
211
+ decoder_params[:arguments] = ['pizza']
212
+
213
+ actual_result = described_class.decode(decoder_params)
214
+
215
+ expect(actual_result).to be(nil)
216
+ end
217
+
218
+ end
219
+
220
+ end
221
+
222
+ end