aspera-cli 4.27.1 → 4.27.3
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +67 -1
- data/bin/ascli +2 -1
- data/docs/README.md +805 -747
- data/lib/aspera/agent/connect.rb +6 -4
- data/lib/aspera/agent/desktop.rb +2 -2
- data/lib/aspera/agent/direct.rb +3 -1
- data/lib/aspera/agent/node.rb +3 -3
- data/lib/aspera/api/alee.rb +1 -1
- data/lib/aspera/api/aoc.rb +14 -12
- data/lib/aspera/api/ats.rb +1 -1
- data/lib/aspera/api/cos_node.rb +2 -2
- data/lib/aspera/api/faspex.rb +9 -7
- data/lib/aspera/api/httpgw.rb +37 -33
- data/lib/aspera/api/node.rb +38 -33
- data/lib/aspera/ascmd.rb +3 -1
- data/lib/aspera/ascp/installation.rb +62 -27
- data/lib/aspera/ascp/management.rb +1 -0
- data/lib/aspera/assert.rb +4 -0
- data/lib/aspera/cli/ascp_actions.rb +20 -41
- data/lib/aspera/cli/async_transfer_store.rb +2 -2
- data/lib/aspera/cli/bootstrapper.rb +11 -15
- data/lib/aspera/cli/command_line.rb +252 -0
- data/lib/aspera/cli/command_registry.rb +149 -33
- data/lib/aspera/cli/command_spec.rb +103 -14
- data/lib/aspera/cli/completion/ascli.bash +12 -0
- data/lib/aspera/cli/completion/ascli.fish +16 -0
- data/lib/aspera/cli/completion/ascli.zsh +19 -0
- data/lib/aspera/cli/context.rb +3 -0
- data/lib/aspera/cli/deprecation.rb +37 -0
- data/lib/aspera/cli/extended_value.rb +2 -0
- data/lib/aspera/cli/formatter.rb +87 -75
- data/lib/aspera/cli/gem_checker.rb +1 -1
- data/lib/aspera/cli/hints.rb +7 -6
- data/lib/aspera/cli/http.rb +21 -21
- data/lib/aspera/cli/info.rb +3 -0
- data/lib/aspera/cli/mcp_tool.rb +47 -83
- data/lib/aspera/cli/option_declarator.rb +33 -42
- data/lib/aspera/cli/option_registry.rb +69 -0
- data/lib/aspera/cli/option_types.rb +103 -0
- data/lib/aspera/cli/option_value.rb +281 -0
- data/lib/aspera/cli/options.schema.yaml +38 -5
- data/lib/aspera/cli/parser.rb +307 -848
- data/lib/aspera/cli/plugins/alee.rb +7 -4
- data/lib/aspera/cli/plugins/aoc.rb +435 -380
- data/lib/aspera/cli/plugins/ats.rb +58 -73
- data/lib/aspera/cli/plugins/base.rb +190 -240
- data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
- data/lib/aspera/cli/plugins/config.rb +244 -178
- data/lib/aspera/cli/plugins/console.rb +102 -38
- data/lib/aspera/cli/plugins/cos.rb +6 -23
- data/lib/aspera/cli/plugins/factory.rb +3 -0
- data/lib/aspera/cli/plugins/faspex5.rb +176 -173
- data/lib/aspera/cli/plugins/faspio.rb +5 -10
- data/lib/aspera/cli/plugins/httpgw.rb +8 -11
- data/lib/aspera/cli/plugins/mcp.rb +20 -55
- data/lib/aspera/cli/plugins/node.rb +277 -311
- data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
- data/lib/aspera/cli/plugins/preview.rb +79 -90
- data/lib/aspera/cli/plugins/server.rb +76 -50
- data/lib/aspera/cli/plugins/shares.rb +68 -116
- data/lib/aspera/cli/preset_actions.rb +17 -10
- data/lib/aspera/cli/preset_manager.rb +12 -2
- data/lib/aspera/cli/prompt.rb +35 -0
- data/lib/aspera/cli/result.rb +13 -18
- data/lib/aspera/cli/runner.rb +31 -54
- data/lib/aspera/cli/special_values.rb +5 -0
- data/lib/aspera/cli/sync_actions.rb +41 -37
- data/lib/aspera/cli/terminal_formatter.rb +9 -3
- data/lib/aspera/cli/transfer_actions.rb +0 -6
- data/lib/aspera/cli/transfer_agent.rb +29 -35
- data/lib/aspera/cli/vault_manager.rb +0 -17
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +4 -2
- data/lib/aspera/command_line_builder.rb +1 -0
- data/lib/aspera/coverage.rb +1 -0
- data/lib/aspera/environment.rb +7 -1
- data/lib/aspera/faspex_gw.rb +2 -1
- data/lib/aspera/faspex_postproc.rb +1 -0
- data/lib/aspera/graphql.rb +5 -5
- data/lib/aspera/json_rpc/client.rb +5 -5
- data/lib/aspera/keychain/encrypted_hash.rb +1 -1
- data/lib/aspera/keychain/one_password_api.rb +1 -1
- data/lib/aspera/link_header.rb +2 -2
- data/lib/aspera/log.rb +22 -25
- data/lib/aspera/markdown.rb +2 -0
- data/lib/aspera/mime.rb +25 -0
- data/lib/aspera/node_simulator.rb +1 -0
- data/lib/aspera/oauth/base.rb +35 -25
- data/lib/aspera/oauth/factory.rb +1 -0
- data/lib/aspera/oauth/generic.rb +1 -1
- data/lib/aspera/oauth/jwt.rb +1 -1
- data/lib/aspera/oauth/web.rb +9 -8
- data/lib/aspera/preview/file_types.rb +4 -4
- data/lib/aspera/preview/generator.rb +7 -0
- data/lib/aspera/preview/options.rb +4 -4
- data/lib/aspera/preview/terminal.rb +4 -3
- data/lib/aspera/preview/utils.rb +9 -6
- data/lib/aspera/products/connect.rb +1 -1
- data/lib/aspera/rainbow.rb +7 -0
- data/lib/aspera/rest/aspera_errors.rb +60 -0
- data/lib/aspera/rest/call_error.rb +27 -0
- data/lib/aspera/rest/client.rb +514 -0
- data/lib/aspera/rest/error_analyzer.rb +113 -0
- data/lib/aspera/rest/list.rb +143 -0
- data/lib/aspera/rest/parameters.rb +55 -0
- data/lib/aspera/rest/util.rb +176 -0
- data/lib/aspera/rest.rb +7 -621
- data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +39 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
- data/lib/aspera/schema/documentation.rb +13 -3
- data/lib/aspera/schema/registry.rb +18 -1
- data/lib/aspera/schema/validator.rb +92 -0
- data/lib/aspera/secret_hider.rb +36 -25
- data/lib/aspera/string_ext.rb +15 -0
- data/lib/aspera/temp_file_manager.rb +6 -5
- data/lib/aspera/transfer/parameters.rb +2 -0
- data/lib/aspera/transfer/spec.rb +1 -0
- data/lib/aspera/transfer/spec.schema.yaml +1 -0
- data/lib/aspera/uri_reader.rb +11 -11
- data/lib/aspera/web_auth/index.html +147 -0
- data/lib/aspera/web_auth/server.rb +81 -0
- data.tar.gz.sig +0 -0
- metadata +39 -7
- metadata.gz.sig +0 -0
- data/lib/aspera/colors.rb +0 -79
- data/lib/aspera/rest_call_error.rb +0 -25
- data/lib/aspera/rest_error_analyzer.rb +0 -111
- data/lib/aspera/rest_errors_aspera.rb +0 -58
- data/lib/aspera/rest_list.rb +0 -136
- data/lib/aspera/web_auth.rb +0 -211
data/lib/aspera/cli/parser.rb
CHANGED
|
@@ -5,417 +5,41 @@ require 'aspera/cli/extended_value'
|
|
|
5
5
|
require 'aspera/cli/error'
|
|
6
6
|
require 'aspera/cli/special_values'
|
|
7
7
|
require 'aspera/cli/terminal_formatter'
|
|
8
|
-
require 'aspera/
|
|
9
|
-
require 'aspera/
|
|
8
|
+
require 'aspera/cli/option_types'
|
|
9
|
+
require 'aspera/cli/option_registry'
|
|
10
|
+
require 'aspera/cli/command_line'
|
|
11
|
+
require 'aspera/cli/prompt'
|
|
12
|
+
require 'aspera/schema/validator'
|
|
10
13
|
require 'aspera/log'
|
|
11
14
|
require 'aspera/assert'
|
|
12
15
|
require 'aspera/dot_container'
|
|
13
|
-
require 'aspera/schema/registry'
|
|
14
|
-
require 'io/console'
|
|
15
16
|
require 'terminal-table'
|
|
17
|
+
require 'aspera/rainbow'
|
|
18
|
+
using Rainbow
|
|
16
19
|
|
|
17
20
|
module Aspera
|
|
18
21
|
module Cli
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# @param schema_path [String, nil] path to schema file, or `nil` if not available
|
|
27
|
-
def initialize(type, name, schema_path)
|
|
28
|
-
super("#{type}: #{name}")
|
|
29
|
-
@path = schema_path
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
module BoolValue
|
|
34
|
-
# boolean options are set to true/false from the following values
|
|
35
|
-
YES_SYM = :yes
|
|
36
|
-
NO_SYM = :no
|
|
37
|
-
FALSE_VALUES = [NO_SYM, false].freeze
|
|
38
|
-
TRUE_VALUES = [YES_SYM, true].freeze
|
|
39
|
-
private_constant :YES_SYM, :NO_SYM, :FALSE_VALUES, :TRUE_VALUES
|
|
40
|
-
# Boolean values
|
|
41
|
-
# @return [Array<true, false, :yes, :no>]
|
|
42
|
-
ALL = (TRUE_VALUES + FALSE_VALUES).freeze
|
|
43
|
-
# `false` and `true`
|
|
44
|
-
TYPES = [FalseClass, TrueClass].freeze
|
|
45
|
-
SYMBOLS = [NO_SYM, YES_SYM].freeze
|
|
46
|
-
# @return [Boolean] `true` if value is a value for `true` in ALL
|
|
47
|
-
def true?(enum)
|
|
48
|
-
Aspera.assert_values(enum, ALL) { 'boolean' }
|
|
49
|
-
TRUE_VALUES.include?(enum)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# @return [:yes, :no]
|
|
53
|
-
def to_sym(enum) = true?(enum) ? YES_SYM : NO_SYM
|
|
54
|
-
|
|
55
|
-
# @return [Boolean] `true` if value is a value for `true` or `false` in ALL
|
|
56
|
-
def symbol?(sym)
|
|
57
|
-
ALL.include?(sym)
|
|
58
|
-
end
|
|
59
|
-
module_function :true?, :to_sym, :symbol?
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Type specifiers for the `allowed:` parameter of option declarations.
|
|
63
|
-
# Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, BOOLEAN, NONE.
|
|
64
|
-
# Internal (do not pass as `allowed:`):
|
|
65
|
-
# ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
|
|
66
|
-
# STRING - the implicit default; equivalent to omitting `allowed:` entirely
|
|
67
|
-
module Type
|
|
68
|
-
# Option value is a String or Array of Strings (cumulative)
|
|
69
|
-
STRING_ARRAY = [Array, String].freeze
|
|
70
|
-
# Option value is a Symbol from a constrained list; use as prefix: SYMBOL_ARRAY + [:val1, :val2]
|
|
71
|
-
SYMBOL_ARRAY = [Array, Symbol].freeze
|
|
72
|
-
# Option value is coerced to Integer
|
|
73
|
-
INTEGER = [Integer].freeze
|
|
74
|
-
# Option value is a Boolean
|
|
75
|
-
BOOLEAN = BoolValue::TYPES
|
|
76
|
-
# Option has no value — it is a flag switch (e.g. `-N`, `--help`)
|
|
77
|
-
NONE = [].freeze
|
|
78
|
-
# Internal: derived when allowed: is an Array<Symbol>; do not pass directly
|
|
79
|
-
ENUM = [Symbol].freeze
|
|
80
|
-
# Internal: implicit default (String); equivalent to omitting allowed: entirely
|
|
81
|
-
STRING = [String].freeze
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Description of option, how to manage
|
|
85
|
-
class OptionValue
|
|
86
|
-
# [Array(Class)] List of allowed types
|
|
87
|
-
attr_reader :types, :sensitive, :schema, :option, :deprecation
|
|
88
|
-
# [Array] List of allowed values (Symbols and specific values)
|
|
89
|
-
attr_accessor :values
|
|
90
|
-
# [String] Help section group name (set by Parser#group)
|
|
91
|
-
attr_accessor :group
|
|
92
|
-
# [Proc, nil] Block to call for flag options (TYPES_NONE)
|
|
93
|
-
attr_accessor :block
|
|
94
|
-
|
|
95
|
-
# @param option [Symbol] Name of option
|
|
96
|
-
# @param description [String, nil] Description for help; if nil, derived from schema
|
|
97
|
-
# @param allowed [nil,Class,Array<Class>,Array<Symbol>] Allowed values
|
|
98
|
-
# @param handler [Hash, nil] Accessor: keys: :o(object) and :m(method); nil for local storage
|
|
99
|
-
# @param deprecation [String] Deprecation message
|
|
100
|
-
# @param schema [String] Declaration of schema
|
|
101
|
-
# `allowed`:
|
|
102
|
-
# - `nil` No validation, so just a string
|
|
103
|
-
# - `Class` The single allowed Class
|
|
104
|
-
# - `Array<Class>` Multiple allowed classes
|
|
105
|
-
# - `Array<Symbol>` List of allowed values
|
|
106
|
-
def initialize(option:, description: nil, allowed: Type::STRING, handler: nil, deprecation: nil, schema: nil)
|
|
107
|
-
Log.log.trace1 { "option: #{option}, allowed: #{allowed}" }
|
|
108
|
-
@option = option
|
|
109
|
-
@description = description
|
|
110
|
-
@group = nil
|
|
111
|
-
@block = nil
|
|
112
|
-
# by default passwords and secrets are sensitive, else specify when declaring the option
|
|
113
|
-
@sensitive = SecretHider.instance.secret?(@option, '')
|
|
114
|
-
@deprecation = deprecation
|
|
115
|
-
@schema = schema
|
|
116
|
-
# Start with local storage; bind_handler wires the delegation if a handler is given.
|
|
117
|
-
@object = nil
|
|
118
|
-
@read_method = nil
|
|
119
|
-
@write_method = nil
|
|
120
|
-
@access = :local
|
|
121
|
-
bind_handler(handler) unless handler.nil?
|
|
122
|
-
@types = nil
|
|
123
|
-
@values = nil
|
|
124
|
-
allowed = infer_allowed_from_schema(schema, allowed) if schema
|
|
125
|
-
apply_allowed(allowed) unless allowed.nil?
|
|
126
|
-
Log.log.trace1 { "declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
private
|
|
130
|
-
|
|
131
|
-
# Derive the `allowed:` value from the schema when not explicitly provided.
|
|
132
|
-
# Returns `allowed` unchanged when the schema provides no usable type information.
|
|
133
|
-
# @param schema [String] schema identifier
|
|
134
|
-
# @param allowed [Object] caller-supplied allowed value (may be nil or Type::STRING)
|
|
135
|
-
# @return [Object] resolved allowed (Hash, Array, or the original value)
|
|
136
|
-
def infer_allowed_from_schema(schema, allowed)
|
|
137
|
-
return allowed unless allowed.nil? || allowed.eql?(Type::STRING)
|
|
138
|
-
|
|
139
|
-
schema_reader = Schema::Registry.instance.reader(schema) rescue nil
|
|
140
|
-
schema_node = schema_reader&.current
|
|
141
|
-
return allowed unless schema_node
|
|
142
|
-
|
|
143
|
-
case schema_node['type']
|
|
144
|
-
when 'object' then return Hash
|
|
145
|
-
when 'array' then return Array
|
|
146
|
-
end
|
|
147
|
-
# No top-level type: inspect oneOf/anyOf branches; if all resolve to 'object', infer Hash
|
|
148
|
-
composite_key = (%w[oneOf anyOf] & schema_node.keys).first
|
|
149
|
-
return allowed unless composite_key
|
|
150
|
-
|
|
151
|
-
branch_types = schema_node[composite_key].map do |branch|
|
|
152
|
-
resolved = branch['$ref'] ? schema_reader.resolve_ref(branch['$ref']).current : branch
|
|
153
|
-
resolved['type']
|
|
154
|
-
end
|
|
155
|
-
if branch_types.all?('object')
|
|
156
|
-
Hash
|
|
157
|
-
else
|
|
158
|
-
Aspera.assert(
|
|
159
|
-
!allowed.nil? && !allowed.eql?(Type::STRING),
|
|
160
|
-
"option :#{@option}: schema '#{schema}' has mixed-type oneOf branches #{branch_types.uniq}: specify allowed: explicitly"
|
|
161
|
-
)
|
|
162
|
-
allowed
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
# Initialise @types and @values from the resolved `allowed` specifier.
|
|
167
|
-
# @param allowed [Class, Array<Class>, Array<Symbol>] resolved allowed value (never nil)
|
|
168
|
-
def apply_allowed(allowed)
|
|
169
|
-
allowed = [allowed] if allowed.is_a?(Class)
|
|
170
|
-
Aspera.assert_type(allowed, Array)
|
|
171
|
-
if allowed.take(Type::SYMBOL_ARRAY.length) == Type::SYMBOL_ARRAY
|
|
172
|
-
# Special case: array of defined symbol values
|
|
173
|
-
@types = Type::SYMBOL_ARRAY
|
|
174
|
-
@values = allowed[Type::SYMBOL_ARRAY.length..]
|
|
175
|
-
assign_value([], where: 'array default', warn_deprecation: false) if value(log: false).nil?
|
|
176
|
-
elsif allowed.all?(Class)
|
|
177
|
-
@types = allowed
|
|
178
|
-
@values = BoolValue::ALL if allowed.eql?(Type::BOOLEAN)
|
|
179
|
-
if @types.first.eql?(Array) && !@types.include?(NilClass) && value(log: false).nil?
|
|
180
|
-
assign_value([], where: 'array default', warn_deprecation: false)
|
|
181
|
-
elsif @types.first.eql?(Hash) && !@types.include?(NilClass) && value(log: false).nil?
|
|
182
|
-
assign_value({}, where: 'hash default', warn_deprecation: false)
|
|
183
|
-
end
|
|
184
|
-
elsif allowed.all?(Symbol)
|
|
185
|
-
@types = Type::ENUM
|
|
186
|
-
@values = allowed
|
|
187
|
-
else
|
|
188
|
-
Aspera.error_unexpected_value(allowed)
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
public
|
|
193
|
-
|
|
194
|
-
# Wire (or re-wire) the getter/setter delegation for this option.
|
|
195
|
-
# Safe to call after construction - used by Parser#set_handler to bind a composed
|
|
196
|
-
# instance variable that did not exist at class-load time (Category C handlers).
|
|
197
|
-
# @param handler [Hash] Accessor hash with keys :o (object) and :m (method symbol)
|
|
198
|
-
# @return [nil]
|
|
199
|
-
def bind_handler(handler)
|
|
200
|
-
Aspera.assert_type(handler, Hash) { 'handler' }
|
|
201
|
-
# Capture any value already stored locally before switching to delegated storage.
|
|
202
|
-
# This transfers defaults (and any preset values already applied) to the new target.
|
|
203
|
-
pending_value = @access.eql?(:local) ? @object : nil
|
|
204
|
-
@object = handler[:o]
|
|
205
|
-
@read_method = handler[:m]
|
|
206
|
-
@write_method = "#{@read_method}=".to_sym
|
|
207
|
-
@access = if @object.respond_to?(@write_method)
|
|
208
|
-
:attr # delegation via attr_accessor-style m / m=
|
|
209
|
-
else
|
|
210
|
-
:custom # delegation via generic 3-arg method m(sym, :get/:set, val)
|
|
211
|
-
end
|
|
212
|
-
Aspera.assert(@object.respond_to?(@read_method)) { "#{@object} does not respond to #{@read_method}" }
|
|
213
|
-
Log.log.trace1 { "bind_handler: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
|
|
214
|
-
# Push the pending local value to the new target if one was stored
|
|
215
|
-
assign_value(pending_value, where: 'bind_handler', warn_deprecation: false) unless pending_value.nil?
|
|
216
|
-
nil
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# @return [String] description of the option: explicit one, or first line of schema description
|
|
220
|
-
def description
|
|
221
|
-
return @description unless @description.nil?
|
|
222
|
-
return if @schema.nil?
|
|
223
|
-
schema_node = Schema::Registry.instance.reader(@schema).current
|
|
224
|
-
first_line = (schema_node['title'] || schema_node['description'].to_s).lines.first.to_s.strip
|
|
225
|
-
first_line.end_with?('.') ? first_line[0..-2] : first_line
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# Reset stored value to nil
|
|
229
|
-
# @return [nil]
|
|
230
|
-
def clear
|
|
231
|
-
@object = nil
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# Get current option value
|
|
235
|
-
# @param log [Boolean] whether to log the value retrieval
|
|
236
|
-
# @return [Object] current value
|
|
237
|
-
def value(log: true)
|
|
238
|
-
current_value =
|
|
239
|
-
case @access
|
|
240
|
-
when :local then @object
|
|
241
|
-
when :attr then @object.send(@read_method)
|
|
242
|
-
when :custom then @object.send(@read_method, @option, :get)
|
|
243
|
-
end
|
|
244
|
-
Log.log.trace1 { "#{@option} -> (#{current_value.class})#{current_value}" } if log
|
|
245
|
-
current_value
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
# Assign value to option.
|
|
249
|
-
# Value can be a `String`, then evaluated with `ExtendedValue`, or directly a value.
|
|
250
|
-
# @param value [String, Object] Value to assign to option
|
|
251
|
-
# @param where [String] Where the value is assigned from
|
|
252
|
-
# @param warn_deprecation [Boolean] Emit deprecation warning (false for internal transfers)
|
|
253
|
-
# @return [nil]
|
|
254
|
-
def assign_value(value, where:, warn_deprecation: true)
|
|
255
|
-
Aspera.assert(!@deprecation, type: :warn) { "Option #{@option} is deprecated: #{@deprecation}" } if warn_deprecation
|
|
256
|
-
new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
|
|
257
|
-
Log.log.trace1 { "#{where}: #{@option} <- (#{new_value.class})#{new_value}" }
|
|
258
|
-
# Per-type coercion: String input from CLI/env/preset is normalized to the expected type.
|
|
259
|
-
# Centralized here so all sources (CLI dispatch, preset, env) go through the same path.
|
|
260
|
-
case @types
|
|
261
|
-
when Type::ENUM
|
|
262
|
-
new_value = Parser.get_from_list(new_value, @option, @values) if new_value.is_a?(String)
|
|
263
|
-
when Type::BOOLEAN
|
|
264
|
-
new_value = Parser.get_from_list(new_value, @option, BoolValue::ALL) if new_value.is_a?(String)
|
|
265
|
-
new_value = BoolValue.true?(new_value)
|
|
266
|
-
when Type::INTEGER
|
|
267
|
-
new_value = Integer(new_value)
|
|
268
|
-
when Type::STRING_ARRAY
|
|
269
|
-
new_value = [new_value] if new_value.is_a?(String)
|
|
270
|
-
when Type::SYMBOL_ARRAY
|
|
271
|
-
new_value = [new_value] if new_value.is_a?(String)
|
|
272
|
-
Aspera.assert_array_all(new_value, String, type: BadArgument)
|
|
273
|
-
new_value = new_value.map { |v| Parser.get_from_list(v, @option, @values) }
|
|
274
|
-
else
|
|
275
|
-
# nil (setting nil on a Hash/Array option resets to empty container)
|
|
276
|
-
new_value = {} if new_value.nil? && @types&.first.eql?(Hash)
|
|
277
|
-
new_value = [] if new_value.nil? && @types&.first.eql?(Array)
|
|
278
|
-
end
|
|
279
|
-
# Skip type validation for the special 'help' value on Hash options: store it as-is
|
|
280
|
-
# so that get_option(schema:) can raise SchemaRequest with the contextual schema later.
|
|
281
|
-
# Note: set_option already raises SchemaRequest when @schema is set, so this path is
|
|
282
|
-
# only reached when @schema is nil (e.g. --query=help before schema is known).
|
|
283
|
-
if new_value.eql?(Parser::HELP) && @types&.include?(Hash)
|
|
284
|
-
store(new_value)
|
|
285
|
-
return
|
|
286
|
-
end
|
|
287
|
-
Aspera.assert_type(new_value, *@types, type: BadArgument) { "Option #{@option}" } if @types
|
|
288
|
-
if new_value.is_a?(Hash) || new_value.is_a?(Array)
|
|
289
|
-
current_value = value(log: false)
|
|
290
|
-
new_value = current_value.deep_merge(new_value) if new_value.is_a?(Hash) && current_value.is_a?(Hash) && !current_value.empty?
|
|
291
|
-
new_value = current_value + new_value if new_value.is_a?(Array) && current_value.is_a?(Array) && !current_value.empty?
|
|
292
|
-
end
|
|
293
|
-
store(new_value)
|
|
294
|
-
Log.log.trace1 { v = value(log: false); "#{@option} <- (#{v.class})#{v}" } # rubocop:disable Style/Semicolon
|
|
295
|
-
nil
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
private
|
|
299
|
-
|
|
300
|
-
# Store value according to access mode
|
|
301
|
-
# @param new_value [Object] value to store
|
|
302
|
-
# @return [Object] stored value
|
|
303
|
-
def store(new_value)
|
|
304
|
-
case @access
|
|
305
|
-
when :local then @object = new_value
|
|
306
|
-
when :attr then @object.send(@write_method, new_value)
|
|
307
|
-
when :custom then @object.send(@read_method, @option, :set, new_value)
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
# Represents a positional (non-option) CLI argument token.
|
|
313
|
-
class Argument
|
|
314
|
-
# @return [String] the raw argument value
|
|
315
|
-
attr_reader :value
|
|
316
|
-
|
|
317
|
-
def initialize(value)
|
|
318
|
-
@value = value
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def to_s = @value
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
# Represents a parsed CLI option token (long or short form).
|
|
325
|
-
# Pre-computed at argv-scan time; resolution against @declared_options happens later in parse_options!
|
|
326
|
-
class Option
|
|
327
|
-
# @return [String] raw token as it appeared in argv (e.g. "--log-level=debug", "-Pval")
|
|
328
|
-
attr_reader :raw
|
|
329
|
-
# @return [String, nil] option name with underscores (e.g. "log_level", "custom"); nil for short options
|
|
330
|
-
attr_reader :name
|
|
331
|
-
# @return [String, nil] single-char short option letter (e.g. "P"), nil for long options
|
|
332
|
-
attr_reader :short_char
|
|
333
|
-
# @return [Array<String>, nil] sub-keys for dot-path notation (e.g. ["field"] for --custom.field),
|
|
334
|
-
# nil when name is the full option name (no dot)
|
|
335
|
-
attr_reader :dot_path
|
|
336
|
-
# @return [String, nil] inline value string, or nil if no value was provided inline
|
|
337
|
-
attr_reader :value
|
|
338
|
-
# @return [Boolean] true if `=` (long) or glued value (short) was present in the raw token;
|
|
339
|
-
# false means no inline separator — value may come from the next argv token
|
|
340
|
-
attr_reader :has_value
|
|
341
|
-
|
|
342
|
-
# @param raw [String] full raw token
|
|
343
|
-
# @param name [String, nil] option name (underscored, no prefix)
|
|
344
|
-
# @param short_char [String, nil] single-char short letter
|
|
345
|
-
# @param dot_path [Array<String>, nil] dot-path sub-keys, or nil
|
|
346
|
-
# @param value [String, nil] inline value
|
|
347
|
-
# @param has_value [Boolean] whether an inline value separator was present
|
|
348
|
-
def initialize(raw:, name:, short_char:, dot_path:, value:, has_value:)
|
|
349
|
-
@raw = raw
|
|
350
|
-
@name = name
|
|
351
|
-
@short_char = short_char
|
|
352
|
-
@dot_path = dot_path
|
|
353
|
-
@value = value
|
|
354
|
-
@has_value = has_value
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
def to_s = @raw
|
|
358
|
-
|
|
359
|
-
class << self
|
|
360
|
-
# Build an Option from a raw long-option token (starts with `--`)
|
|
361
|
-
# @param raw [String] e.g. "--log-level=debug" or "--custom.field" or "--log-level"
|
|
362
|
-
def from_long(raw)
|
|
363
|
-
without_prefix = raw.delete_prefix(PREFIX)
|
|
364
|
-
eq_idx = without_prefix.index(VALUE_SEP)
|
|
365
|
-
if eq_idx
|
|
366
|
-
name_raw = without_prefix[0, eq_idx]
|
|
367
|
-
value = without_prefix[eq_idx + 1..]
|
|
368
|
-
has_value = true
|
|
369
|
-
else
|
|
370
|
-
name_raw = without_prefix
|
|
371
|
-
value = nil
|
|
372
|
-
has_value = false
|
|
373
|
-
end
|
|
374
|
-
parts = name_raw.split(DotContainer::SEPARATOR)
|
|
375
|
-
root = parts.shift.gsub(NAME_SEP_LINE, NAME_SEP_SYMBOL)
|
|
376
|
-
dot_path = parts.empty? ? nil : parts
|
|
377
|
-
new(raw: raw, name: root, short_char: nil, dot_path: dot_path, value: value, has_value: has_value)
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
# Build an Option from a raw short-option token (starts with `-` but not `--`)
|
|
381
|
-
# @param raw [String] e.g. "-P", "-Pval", "-h"
|
|
382
|
-
def from_short(raw)
|
|
383
|
-
short_char = raw[1]
|
|
384
|
-
if raw.length > 2
|
|
385
|
-
new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: raw[2..], has_value: true)
|
|
386
|
-
else
|
|
387
|
-
new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: nil, has_value: false)
|
|
388
|
-
end
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Option name separator on command line (e.g. `--option-name`, the `-` between words)
|
|
393
|
-
NAME_SEP_LINE = '-'
|
|
394
|
-
# Option name separator in code/symbol (e.g. `:option_name`, the `_` between words)
|
|
395
|
-
NAME_SEP_SYMBOL = '_'
|
|
396
|
-
# Separator between option name and its inline value (e.g. `--opt=val`, the `=`)
|
|
397
|
-
VALUE_SEP = '='
|
|
398
|
-
# Long-option prefix (e.g. `--opt`)
|
|
399
|
-
PREFIX = '--'
|
|
400
|
-
# (public: shared with Parser)
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
# parse command line options
|
|
404
|
-
# arguments options start with '-', others are commands
|
|
405
|
-
# resolves on extended value syntax
|
|
22
|
+
# Parse command line options and positional arguments.
|
|
23
|
+
#
|
|
24
|
+
# Options are declared incrementally (global, then plugin options).
|
|
25
|
+
# Command line options are applied when their option is declared: explicitly with `parse_options!`,
|
|
26
|
+
# or automatically on next read of an option or argument. Unknown options are kept for later.
|
|
27
|
+
#
|
|
28
|
+
# Option values come from several sources, see `OptionSource`: highest priority wins, whatever the order.
|
|
406
29
|
class Parser
|
|
30
|
+
include Prompt
|
|
31
|
+
|
|
407
32
|
class << self
|
|
408
33
|
# Find shortened string value in allowed symbol list
|
|
409
|
-
# @param short_value
|
|
410
|
-
# @param descr
|
|
411
|
-
# @param allowed_values [Array]
|
|
34
|
+
# @param short_value [String] Value or prefix to find
|
|
35
|
+
# @param descr [String] Description for error messages
|
|
36
|
+
# @param allowed_values [Array] List of allowed values
|
|
412
37
|
# @return [Symbol, Boolean] matched symbol or boolean value
|
|
413
38
|
def get_from_list(short_value, descr, allowed_values)
|
|
414
39
|
Aspera.assert_type(short_value, String)
|
|
415
40
|
# we accept shortcuts
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
matching = allowed_values.select { |i| i.to_s.start_with?(short_value) }
|
|
41
|
+
matching = allowed_values.select { |i| i.to_s.eql?(short_value) }
|
|
42
|
+
matching = allowed_values.select { |i| i.to_s.start_with?(short_value) } unless matching.length.eql?(1)
|
|
419
43
|
raise BadArgument, "Identifier '#{short_value}' used where a #{descr} is expected: place the identifier after the command" if matching.empty? && short_value.match?(REGEX_LOOKUP_ID_BY_FIELD)
|
|
420
44
|
Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
|
|
421
45
|
Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
|
|
@@ -423,14 +47,6 @@ module Aspera
|
|
|
423
47
|
matching.first
|
|
424
48
|
end
|
|
425
49
|
|
|
426
|
-
# Find a key in a list by exact match or unique prefix match
|
|
427
|
-
# @return [Object, nil] the matching key, or nil if none or ambiguous
|
|
428
|
-
def match_prefix(short_value, allowed_values)
|
|
429
|
-
return short_value if allowed_values.include?(short_value)
|
|
430
|
-
matches = allowed_values.select { |k| k.to_s.start_with?(short_value.to_s) }
|
|
431
|
-
matches.length == 1 ? matches.first : nil
|
|
432
|
-
end
|
|
433
|
-
|
|
434
50
|
# Generates error message with list of allowed values
|
|
435
51
|
# @param error_msg [String] Error message
|
|
436
52
|
# @param accept_list [Array<Symbol>] List of allowed values
|
|
@@ -473,12 +89,13 @@ module Aspera
|
|
|
473
89
|
end
|
|
474
90
|
|
|
475
91
|
# Using dotted hash notation, convert value to bool, int, float or extended value
|
|
92
|
+
# `true` and `yes` are converted to `true`, `false` and `no` to `false`
|
|
476
93
|
# @param value [String] The value to convert to appropriate type
|
|
477
94
|
# @return [Boolean, Integer, Float, String, Array, Hash] the converted value
|
|
478
95
|
def smart_convert(value)
|
|
479
96
|
case value
|
|
480
|
-
when 'true'
|
|
481
|
-
when 'false' then false
|
|
97
|
+
when 'true', BoolValue::YES_SYM.to_s then true
|
|
98
|
+
when 'false', BoolValue::NO_SYM.to_s then false
|
|
482
99
|
else
|
|
483
100
|
Integer(value, exception: false) ||
|
|
484
101
|
Float(value, exception: false) ||
|
|
@@ -487,76 +104,32 @@ module Aspera
|
|
|
487
104
|
end
|
|
488
105
|
end
|
|
489
106
|
|
|
490
|
-
attr_accessor :ask_missing_mandatory, :ask_missing_optional
|
|
107
|
+
attr_accessor :ask_missing_mandatory, :ask_missing_optional
|
|
491
108
|
attr_writer :fail_on_missing_mandatory
|
|
492
109
|
|
|
493
110
|
# @param program_name [String] Name of the program
|
|
494
111
|
# @param argv [Array<String>, nil] Command line arguments to parse
|
|
495
112
|
def initialize(program_name, argv = nil)
|
|
496
|
-
|
|
497
|
-
# @type [Hash{Symbol => OptionValue}]
|
|
498
|
-
@declared_options = {}
|
|
113
|
+
@registry = OptionRegistry.new
|
|
499
114
|
# do we ask missing options and arguments to user ?
|
|
500
115
|
@ask_missing_mandatory = false # STDIN.isatty
|
|
501
116
|
# ask optional options if not provided and in interactive
|
|
502
117
|
@ask_missing_optional = false
|
|
503
118
|
# get_option fails if a mandatory parameter is asked
|
|
504
119
|
@fail_on_missing_mandatory = true
|
|
505
|
-
#
|
|
506
|
-
@
|
|
507
|
-
#
|
|
508
|
-
@
|
|
120
|
+
# Values from presets and env vars, for options not declared yet: option symbol -> [{value:, source:}]
|
|
121
|
+
@pending_values = {}
|
|
122
|
+
# `true` when options were declared or presets added since last parse
|
|
123
|
+
@parse_needed = true
|
|
509
124
|
# options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
|
|
510
|
-
@option_pairs_batch = {}
|
|
511
|
-
@option_pairs_env = {}
|
|
512
|
-
# Short option char -> option symbol, e.g. {'h' => :help, 'v' => :version}
|
|
513
|
-
@short_options = {}
|
|
514
|
-
# Current help section group name, set by #group
|
|
515
|
-
@current_group = 'global'
|
|
516
125
|
env_prefix = program_name.upcase + Option::NAME_SEP_SYMBOL
|
|
517
126
|
ENV.each do |k, v|
|
|
518
|
-
|
|
127
|
+
add_pending_value(k.delete_prefix(env_prefix).downcase.to_sym, v, :env, replace: true) if k.start_with?(env_prefix)
|
|
519
128
|
end
|
|
520
|
-
Log.dump(:env, @
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
@argv_tokens = []
|
|
525
|
-
# Frozen snapshot of @argv_tokens used by unprocessed_options_with_value.
|
|
526
|
-
@initial_argv_tokens = [].freeze
|
|
527
|
-
# Number of original positional args before the option currently being parsed (nil = positional context).
|
|
528
|
-
@current_option_args_offset = nil
|
|
529
|
-
# Current index in @argv_tokens during parse_options! loop (used by shift_next_argument_token).
|
|
530
|
-
@current_parse_idx = nil
|
|
531
|
-
return if argv.nil?
|
|
532
|
-
# true until `--` is found (stop options)
|
|
533
|
-
process_options = true
|
|
534
|
-
argv.each do |value|
|
|
535
|
-
if process_options && value.start_with?('-')
|
|
536
|
-
Log.log.trace1 { "opt: #{value}" }
|
|
537
|
-
if value.eql?(OPTIONS_STOP)
|
|
538
|
-
process_options = false
|
|
539
|
-
else
|
|
540
|
-
@argv_tokens.push(value.start_with?(Option::PREFIX) ? Option.from_long(value) : Option.from_short(value))
|
|
541
|
-
end
|
|
542
|
-
else
|
|
543
|
-
Log.log.trace1 { "arg: #{value}" }
|
|
544
|
-
@argv_tokens.push(Argument.new(value))
|
|
545
|
-
end
|
|
546
|
-
end
|
|
547
|
-
@initial_argv_tokens = @argv_tokens.dup.freeze
|
|
548
|
-
Log.log.trace1 { "add_cmd_line_options:arguments=#{pending_arguments},options=#{pending_options}".red }
|
|
549
|
-
declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
|
|
550
|
-
declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_optional})
|
|
551
|
-
# do not parse options yet, let's wait for option `-h` to be overridden
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
# Add a type to the message if not special types
|
|
555
|
-
# @param types [Array<Class>] types to add
|
|
556
|
-
# @return [String] Types if relevant
|
|
557
|
-
def add_types_info(types)
|
|
558
|
-
return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
|
|
559
|
-
" (#{types.map(&:name).join(', ')})"
|
|
129
|
+
Log.dump(:env, @pending_values)
|
|
130
|
+
@command_line = CommandLine.new(argv || [])
|
|
131
|
+
declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, default: false, on_set: method(:ask_missing_mandatory=))
|
|
132
|
+
declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, default: false, on_set: method(:ask_missing_optional=))
|
|
560
133
|
end
|
|
561
134
|
|
|
562
135
|
# Declare an option
|
|
@@ -570,70 +143,71 @@ module Aspera
|
|
|
570
143
|
# - Use `allowed: [Hash, String]` when the option additionally accepts a plain String shorthand;
|
|
571
144
|
# the schema then documents the Hash form and `=help` still shows it.
|
|
572
145
|
# @param default [Object] default value
|
|
573
|
-
# @param
|
|
574
|
-
#
|
|
146
|
+
# @param on_set [#call] Called with the new value each time the value is set (e.g. a `Method`, or a lambda).
|
|
147
|
+
# For a flag (`Type::NONE`): called without argument when the flag is found
|
|
148
|
+
# @param shorthand [String] For a `Hash` option: a `String` value is stored as `{shorthand => value}`
|
|
149
|
+
# @param deprecation [Hash, nil] deprecation: `{last:, message:}`, see `Deprecation`
|
|
575
150
|
# @param schema [String] schema path documenting the Hash form of this option
|
|
576
151
|
# @param block [Proc] Block to execute when option is found
|
|
577
|
-
def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil,
|
|
152
|
+
def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, on_set: nil, shorthand: nil, deprecation: nil, schema: nil, &block)
|
|
578
153
|
Aspera.assert_type(option_symbol, Symbol)
|
|
579
|
-
Aspera.assert(!@
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
154
|
+
Aspera.assert(!@registry.declared?(option_symbol)) { "#{option_symbol} already declared" }
|
|
155
|
+
if on_set && allowed.eql?(Type::NONE)
|
|
156
|
+
Aspera.assert(block.nil?) { "#{option_symbol}: flag with both on_set and block" }
|
|
157
|
+
block = on_set
|
|
158
|
+
on_set = nil
|
|
159
|
+
end
|
|
160
|
+
# An abbreviation already used on command line must stay unambiguous
|
|
161
|
+
@command_line.abbreviated_option_tokens.each do |tok|
|
|
162
|
+
Aspera.assert(!option_symbol.to_s.start_with?(tok.name), type: BadArgument) do
|
|
163
|
+
"Ambiguous option #{tok.raw}: used as #{self.class.option_name_to_line(tok.abbreviation_of)}, but also matches #{self.class.option_name_to_line(option_symbol)}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
option = @registry.add(
|
|
167
|
+
OptionValue.new(
|
|
168
|
+
option: option_symbol,
|
|
169
|
+
description: description,
|
|
170
|
+
allowed: allowed,
|
|
171
|
+
on_set: on_set,
|
|
172
|
+
shorthand: shorthand,
|
|
173
|
+
deprecation: deprecation,
|
|
174
|
+
schema: schema
|
|
175
|
+
),
|
|
176
|
+
short: short
|
|
589
177
|
)
|
|
590
|
-
|
|
591
|
-
description = option_attrs.description
|
|
178
|
+
description = option.description
|
|
592
179
|
Aspera.assert(!description.nil?) { "#{option_symbol}: no description and no schema to derive one from" }
|
|
593
180
|
Aspera.assert(description[-1] != '.') { "#{option_symbol} ends with dot" }
|
|
594
181
|
Aspera.assert(description[0] == description[0].upcase) { "#{option_symbol} description does not start with an uppercase" }
|
|
595
182
|
Aspera.assert(!['hash', 'extended value'].any? { |s| description.downcase.include?(s) }) { "#{option_symbol} shall use :allowed instead of hash/extended value in option description" }
|
|
596
|
-
set_option(option_symbol, default,
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
set_option(option_symbol, BoolValue.true?(default), where: 'default', warn_deprecation: false) if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
|
|
601
|
-
when Type::NONE
|
|
602
|
-
Aspera.assert_type(block, Proc) { "missing execution block for #{option_symbol}" }
|
|
603
|
-
option_attrs.block = block
|
|
183
|
+
set_option(option_symbol, default, source: :default, warn_deprecation: false) unless default.nil?
|
|
184
|
+
if option.flag?
|
|
185
|
+
Aspera.assert(block.respond_to?(:call)) { "missing execution block for #{option_symbol}" }
|
|
186
|
+
option.block = block
|
|
604
187
|
end
|
|
605
|
-
@
|
|
606
|
-
Log.log.trace1 { "declare: #{option_symbol}, group: #{
|
|
188
|
+
@parse_needed = true
|
|
189
|
+
Log.log.trace1 { "declare: #{option_symbol}, group: #{option.group}, short: #{short}" }
|
|
607
190
|
end
|
|
608
191
|
|
|
609
192
|
# Set the current help section group name for subsequent declarations
|
|
610
193
|
# @param name [String] group name, shown as section header in help text
|
|
611
194
|
def group(name)
|
|
612
|
-
@
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
# Rename all options currently tagged with @current_group to a new name,
|
|
616
|
-
# then update @current_group. Used by add_manual_header when a plugin
|
|
617
|
-
# declares its options before its group name is known (e.g. Plugins::Config).
|
|
618
|
-
# @param name [String] new group name
|
|
619
|
-
def rename_current_group(name)
|
|
620
|
-
@declared_options.each_value { |opt| opt.group = name if opt.group.eql?(@current_group) }
|
|
621
|
-
@current_group = name
|
|
195
|
+
@registry.group = name
|
|
622
196
|
end
|
|
623
197
|
|
|
624
|
-
# Low-level positional argument reader.
|
|
625
|
-
#
|
|
626
|
-
#
|
|
627
|
-
# @
|
|
628
|
-
# @param descr [String] description for help
|
|
198
|
+
# Low-level positional argument reader.
|
|
199
|
+
# Prefer `Base#resolve_argument` from action methods.
|
|
200
|
+
# The only direct call from outside `Cli::Parser` and `Cli::Plugins::Base` is to retrieve the list of files.
|
|
201
|
+
# @param descr [String] Description for help
|
|
629
202
|
# @param mandatory [Boolean] `true`: raise error no more argument
|
|
630
203
|
# @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
|
|
631
|
-
# @param accept_list [Array<Symbol>,
|
|
632
|
-
# @param validation [Class, Array,
|
|
204
|
+
# @param accept_list [Array<Symbol>, nil] list of allowed values
|
|
205
|
+
# @param validation [Class, Array, nil] Accepted value type(s) or list of Symbols
|
|
633
206
|
# @param aliases [Hash] map of aliases: key = alias, value = real value
|
|
634
207
|
# @param default [Object] default value
|
|
635
208
|
# @return [Object, Array, nil] one value, list or nil (if optional and no default)
|
|
636
209
|
def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil)
|
|
210
|
+
ensure_parsed
|
|
637
211
|
Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
|
|
638
212
|
Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
|
|
639
213
|
validation = Symbol unless accept_list.nil?
|
|
@@ -641,34 +215,14 @@ module Aspera
|
|
|
641
215
|
Aspera.assert_array_all(validation, Class) { 'validation' } unless validation.nil?
|
|
642
216
|
descr = "#{descr}#{add_types_info(validation)}"
|
|
643
217
|
result =
|
|
644
|
-
if
|
|
645
|
-
values = extract_argument_tokens(multiple)
|
|
646
|
-
values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
|
|
647
|
-
# If expecting list and only one arg of type array : it is the list
|
|
648
|
-
values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
|
|
649
|
-
if accept_list
|
|
650
|
-
allowed_values = [].concat(accept_list)
|
|
651
|
-
allowed_values.concat(aliases.keys) unless aliases.nil?
|
|
652
|
-
values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
|
|
653
|
-
end
|
|
654
|
-
multiple ? values : values.first
|
|
218
|
+
if !@command_line.pending_arguments.empty? then read_arguments(descr, multiple: multiple, validation: validation, accept_list: accept_list, aliases: aliases)
|
|
655
219
|
elsif !default.nil? then default
|
|
656
220
|
elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, aliases: aliases, schema: schema)
|
|
657
221
|
end
|
|
658
222
|
Log.log.trace1 { "#{descr}=#{result}" }
|
|
659
223
|
result = aliases[result] if aliases&.key?(result)
|
|
660
|
-
|
|
661
|
-
result
|
|
662
|
-
# Integer coercion: a String argument for an INTEGER option must be parseable
|
|
663
|
-
if result.is_a?(String) && validation&.eql?(Type::INTEGER)
|
|
664
|
-
int_result = Integer(result, exception: false)
|
|
665
|
-
raise Cli::BadArgument, "Invalid integer: #{result}" if int_result.nil?
|
|
666
|
-
result = int_result
|
|
667
|
-
end
|
|
668
|
-
if validation && (mandatory || !result.nil?)
|
|
669
|
-
value_list = multiple ? result : [result]
|
|
670
|
-
value_list.each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) }
|
|
671
|
-
end
|
|
224
|
+
result = convert_argument(result, validation)
|
|
225
|
+
(multiple ? result : [result]).each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) } if validation && (mandatory || !result.nil?)
|
|
672
226
|
result
|
|
673
227
|
end
|
|
674
228
|
|
|
@@ -700,23 +254,21 @@ module Aspera
|
|
|
700
254
|
# @param option_symbol [Symbol] name of the option
|
|
701
255
|
# @return [Boolean]
|
|
702
256
|
def option_declared?(option_symbol)
|
|
703
|
-
@
|
|
257
|
+
@registry.declared?(option_symbol)
|
|
704
258
|
end
|
|
705
259
|
|
|
706
260
|
# @return [Hash{Symbol => OptionValue}] all declared options (read-only view)
|
|
707
|
-
|
|
261
|
+
def declared_options = @registry.options
|
|
708
262
|
|
|
709
263
|
# Get an option definition by name
|
|
710
264
|
# @param option_symbol [Symbol] name of the option
|
|
711
265
|
# @return [OptionValue] Option definition
|
|
712
266
|
# @raise [Cli::BadArgument] if option not found
|
|
713
267
|
def option_def(option_symbol)
|
|
714
|
-
|
|
715
|
-
@declared_options[option_symbol]
|
|
268
|
+
@registry.fetch(option_symbol)
|
|
716
269
|
end
|
|
717
270
|
|
|
718
|
-
# Get an option value by name
|
|
719
|
-
# either return value or calls handler, can return nil
|
|
271
|
+
# Get an option value by name, can return nil
|
|
720
272
|
# ask interactively if requested/required
|
|
721
273
|
# @param option_symbol [Symbol] name of the option to retrieve
|
|
722
274
|
# @param mandatory [Boolean] if true, raise error if option not set
|
|
@@ -724,37 +276,32 @@ module Aspera
|
|
|
724
276
|
# if the option value is 'help' (used for --query whose schema depends on the current command)
|
|
725
277
|
def get_option(option_symbol, mandatory: false, schema: nil)
|
|
726
278
|
Aspera.assert_type(option_symbol, Symbol)
|
|
727
|
-
|
|
728
|
-
|
|
279
|
+
ensure_parsed
|
|
280
|
+
option = option_def(option_symbol)
|
|
281
|
+
result = option.value
|
|
729
282
|
# Contextual schema: raise SchemaRequest when value is 'help'
|
|
730
|
-
raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(
|
|
283
|
+
raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(SchemaRequest::KEYWORD)
|
|
731
284
|
# Do not fail for manual generation if option mandatory but not set
|
|
732
285
|
return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
|
|
733
286
|
if result.nil?
|
|
734
287
|
if !@ask_missing_mandatory
|
|
735
288
|
Aspera.assert(!mandatory, type: Cli::BadArgument) { "Missing mandatory option: #{option_symbol}" }
|
|
736
289
|
elsif @ask_missing_optional || mandatory
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
set_option(option_symbol, result, where: 'interactive')
|
|
290
|
+
result = get_interactive(option_symbol.to_s, accept_list: option.values, schema: option.schema)
|
|
291
|
+
set_option(option_symbol, result, source: :interactive)
|
|
740
292
|
end
|
|
741
293
|
end
|
|
742
294
|
result
|
|
743
295
|
end
|
|
744
296
|
|
|
745
|
-
# Set an option value by name
|
|
297
|
+
# Set an option value by name: store value and call the `on_set` callback
|
|
746
298
|
# String is given to extended value
|
|
747
299
|
# @param option_symbol [Symbol] option name
|
|
748
|
-
# @param value
|
|
749
|
-
# @param
|
|
750
|
-
def set_option(option_symbol, value,
|
|
300
|
+
# @param value [String] Value to set
|
|
301
|
+
# @param source [Symbol] `OptionSource` of value
|
|
302
|
+
def set_option(option_symbol, value, source: :code, warn_deprecation: true)
|
|
751
303
|
Aspera.assert_type(option_symbol, Symbol)
|
|
752
|
-
|
|
753
|
-
# Raise immediately only when the option has a static schema: the schema is known at parse time.
|
|
754
|
-
# When schema is nil (e.g. --query), 'help' is stored as-is and SchemaRequest is raised later
|
|
755
|
-
# in get_option() with the contextual schema provided by the calling command.
|
|
756
|
-
raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP) && option.schema
|
|
757
|
-
option.assign_value(value, where: where, warn_deprecation: warn_deprecation)
|
|
304
|
+
option_def(option_symbol).assign_value(value, source: source, warn_deprecation: warn_deprecation)
|
|
758
305
|
end
|
|
759
306
|
|
|
760
307
|
# Set option to `nil`
|
|
@@ -765,72 +312,73 @@ module Aspera
|
|
|
765
312
|
option_def(option_symbol).clear
|
|
766
313
|
end
|
|
767
314
|
|
|
768
|
-
# Bind (or re-bind)
|
|
769
|
-
#
|
|
770
|
-
# (e.g. @gen_options) is created after class-load time.
|
|
315
|
+
# Bind (or re-bind) an `on_set` callback to an already-declared option, for a target object created after declaration.
|
|
316
|
+
# The callback is called with the current value, if any.
|
|
771
317
|
# @param option_symbol [Symbol] name of the already-declared option
|
|
772
|
-
# @param
|
|
773
|
-
# @param method [Symbol] accessor method name on object
|
|
318
|
+
# @param callback [#call] called with the new value each time the value is set (e.g. a `Method`)
|
|
774
319
|
# @return [nil]
|
|
775
|
-
def
|
|
320
|
+
def on_set(option_symbol, callback)
|
|
776
321
|
Aspera.assert_type(option_symbol, Symbol)
|
|
777
|
-
option_def(option_symbol).
|
|
322
|
+
option_def(option_symbol).bind_on_set(callback)
|
|
778
323
|
end
|
|
779
324
|
|
|
780
|
-
# Adds each of the keys of specified hash as an option
|
|
325
|
+
# Adds each of the keys of specified hash as an option.
|
|
326
|
+
# Values are applied by the next parse, and never override a value from env or command line.
|
|
781
327
|
# @param preset_hash [Hash] Options to add
|
|
782
|
-
# @param where [String] Where the value comes from
|
|
783
|
-
# @param override [Boolean]
|
|
328
|
+
# @param where [String] Where the value comes from (for logs)
|
|
329
|
+
# @param override [Boolean] `false` for plugin default presets: lower priority than other presets
|
|
784
330
|
def add_option_preset(preset_hash, where, override: true)
|
|
785
331
|
Aspera.assert_type(preset_hash, Hash)
|
|
786
332
|
Log.log.debug { "add_option_preset: #{preset_hash}, #{where}, #{override}" }
|
|
333
|
+
source = override ? :preset : :plugin_preset
|
|
787
334
|
preset_hash.each do |k, v|
|
|
788
335
|
# Ignore comment/meta keys (e.g. _comment, _description)
|
|
789
336
|
next if k.to_s.start_with?(PresetManager::Key::META_PREFIX)
|
|
790
|
-
|
|
791
|
-
# Never restore an option that was explicitly cleared from the CLI (e.g. --opt=@none:)
|
|
792
|
-
next if @explicitly_cleared.key?(option_symbol)
|
|
793
|
-
@option_pairs_batch[option_symbol] = v if override || !@option_pairs_batch.key?(option_symbol)
|
|
337
|
+
add_pending_value(k.to_sym, v, source, replace: override)
|
|
794
338
|
end
|
|
339
|
+
@parse_needed = true
|
|
795
340
|
end
|
|
796
341
|
|
|
797
342
|
# Allows a plugin to add an argument as next argument to process
|
|
798
343
|
# @param argument [String] argument value to prepend
|
|
799
|
-
# @return [
|
|
344
|
+
# @return [nil]
|
|
800
345
|
def unshift_next_argument(argument)
|
|
801
|
-
@
|
|
346
|
+
@command_line.unshift_argument(argument)
|
|
347
|
+
nil
|
|
802
348
|
end
|
|
803
349
|
|
|
804
350
|
# Check if there are no pending positional arguments
|
|
805
351
|
# @return [Boolean] true if no pending positional arguments
|
|
806
352
|
def command_or_arg_empty?
|
|
807
|
-
|
|
353
|
+
ensure_parsed
|
|
354
|
+
@command_line.pending_arguments.empty?
|
|
808
355
|
end
|
|
809
356
|
|
|
810
357
|
# Check for unprocessed options or arguments error messages
|
|
811
358
|
# @return [Array<String>] list of error messages for unprocessed tokens
|
|
812
359
|
def final_errors
|
|
360
|
+
begin
|
|
361
|
+
ensure_parsed
|
|
362
|
+
rescue StandardError => e
|
|
363
|
+
# already in error processing: report only unprocessed tokens
|
|
364
|
+
Log.log.debug { "final parse: #{e}" }
|
|
365
|
+
end
|
|
813
366
|
result = []
|
|
814
|
-
result.push("unprocessed options: #{pending_options}") unless pending_options.empty?
|
|
815
|
-
result.push("unprocessed values: #{pending_arguments}") unless pending_arguments.empty?
|
|
367
|
+
result.push("unprocessed options: #{@command_line.pending_options}") unless @command_line.pending_options.empty?
|
|
368
|
+
result.push("unprocessed values: #{@command_line.pending_arguments}") unless @command_line.pending_arguments.empty?
|
|
816
369
|
result
|
|
817
370
|
end
|
|
818
371
|
|
|
819
|
-
# Get all
|
|
820
|
-
#
|
|
372
|
+
# Get all long options with a value from command line, used to generate a config in config file.
|
|
373
|
+
# They are marked as processed.
|
|
374
|
+
# @return [Hash] options with value, dotted notation expanded
|
|
821
375
|
def unprocessed_options_with_value
|
|
376
|
+
ensure_parsed
|
|
822
377
|
result = {}
|
|
823
|
-
@
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
# ignore options without value
|
|
828
|
-
next if value.nil?
|
|
829
|
-
name = tok.dot_path ? [tok.name, *tok.dot_path].join(DotContainer::SEPARATOR) : tok.name
|
|
830
|
-
Log.log.debug { "option #{name}=#{value}" }
|
|
831
|
-
path = [tok.name, *(tok.dot_path || [])]
|
|
832
|
-
DotContainer.dotted_to_container(path, Parser.smart_convert(value), result)
|
|
833
|
-
@argv_tokens.reject! { |t| t.is_a?(Option) && t.raw.eql?(tok.raw) }
|
|
378
|
+
@command_line.each_long_option_with_value do |tok|
|
|
379
|
+
path = [tok.name, *tok.dot_path]
|
|
380
|
+
Log.log.debug { "option #{path.join(DotContainer::SEPARATOR)}=#{tok.value}" }
|
|
381
|
+
DotContainer.dotted_to_container(path, Parser.smart_convert(tok.value), result)
|
|
834
382
|
end
|
|
835
383
|
result
|
|
836
384
|
end
|
|
@@ -839,7 +387,7 @@ module Aspera
|
|
|
839
387
|
# @return [Hash] options as taken from config file and command line just before command execution
|
|
840
388
|
def known_options(only_defined: false)
|
|
841
389
|
result = {}
|
|
842
|
-
@
|
|
390
|
+
@registry.options.each_key do |option_symbol|
|
|
843
391
|
v = get_option(option_symbol)
|
|
844
392
|
result[option_symbol] = v unless only_defined && v.nil?
|
|
845
393
|
rescue => e
|
|
@@ -848,186 +396,43 @@ module Aspera
|
|
|
848
396
|
result
|
|
849
397
|
end
|
|
850
398
|
|
|
851
|
-
#
|
|
399
|
+
# Apply values of options declared so far: from presets, env vars and command line.
|
|
400
|
+
# Can be called any number of times: tokens of options not declared yet are kept for a later call.
|
|
401
|
+
# Called automatically on read of option or argument, but must be called explicitly
|
|
402
|
+
# when values set by `on_set` callbacks are used.
|
|
852
403
|
def parse_options!
|
|
853
404
|
Log.log.trace1('parse_options!'.red)
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
# token directly, without any secondary index.
|
|
861
|
-
# Process one option at a time so that @current_option_args_offset can be set before each
|
|
862
|
-
# option is evaluated (used by `@:` extended value).
|
|
863
|
-
deferred_tokens = []
|
|
864
|
-
Log.log.trace1('Before parse')
|
|
865
|
-
Log.dump(:argv_tokens, @argv_tokens, level: :trace1)
|
|
866
|
-
# Iterate with an index so that Argument tokens stay in @argv_tokens (not shifted).
|
|
867
|
-
# Only Option tokens are removed; Arguments remain until consumed by extract_argument_tokens.
|
|
868
|
-
@current_parse_idx = 0
|
|
869
|
-
while @current_parse_idx < @argv_tokens.length
|
|
870
|
-
tok = @argv_tokens[@current_parse_idx]
|
|
871
|
-
if tok.is_a?(Argument)
|
|
872
|
-
@current_parse_idx += 1
|
|
873
|
-
next
|
|
874
|
-
end
|
|
875
|
-
# tok is an Option — remove it from @argv_tokens
|
|
876
|
-
@argv_tokens.delete_at(@current_parse_idx)
|
|
877
|
-
# @current_option_args_offset = number of positional Argument tokens that appear BEFORE
|
|
878
|
-
# this option in @argv_tokens (i.e. at indices 0...@current_parse_idx after the delete).
|
|
879
|
-
# Used by args_as_extended to skip those leading args when collecting @: values.
|
|
880
|
-
@current_option_args_offset = @argv_tokens[0...@current_parse_idx].count { |t| t.is_a?(Argument) }
|
|
881
|
-
if tok.short_char
|
|
882
|
-
# Short option: -X or -Xvalue
|
|
883
|
-
option_sym = @short_options[tok.short_char]
|
|
884
|
-
if option_sym
|
|
885
|
-
raw_value = tok.value
|
|
886
|
-
# No inline value and option expects a value: consume the next :argument token
|
|
887
|
-
raw_value = shift_next_argument_token \
|
|
888
|
-
if !tok.has_value && !@declared_options[option_sym].types.eql?(Type::NONE)
|
|
889
|
-
dispatch_option(option_sym, raw_value)
|
|
890
|
-
else
|
|
891
|
-
deferred_tokens.push(tok)
|
|
892
|
-
end
|
|
893
|
-
elsif tok.dot_path
|
|
894
|
-
# Dotted notation: --a.b.c=val or --a.b.c val (always takes priority over plain option lookup)
|
|
895
|
-
Log.log.trace1 { "Dotted option: #{tok.raw}".red }
|
|
896
|
-
if tok.has_value
|
|
897
|
-
raw_value = tok.value
|
|
898
|
-
value_from_next_token = false
|
|
899
|
-
else
|
|
900
|
-
raw_value = shift_next_argument_token
|
|
901
|
-
value_from_next_token = true
|
|
902
|
-
end
|
|
903
|
-
if @declared_options.key?(tok.name.to_sym)
|
|
904
|
-
set_option(tok.name.to_sym, DotContainer.dotted_to_container(tok.dot_path, Parser.smart_convert(raw_value), get_option(tok.name.to_sym)), where: 'dotted')
|
|
905
|
-
else
|
|
906
|
-
# Only re-inject if value was space-separated (consumed from next token); inline values stay in tok.value
|
|
907
|
-
@argv_tokens.unshift(Argument.new(raw_value)) if raw_value && value_from_next_token
|
|
908
|
-
deferred_tokens.push(tok)
|
|
909
|
-
end
|
|
910
|
-
elsif (resolved_sym = self.class.match_prefix(tok.name.to_sym, @declared_options.keys))
|
|
911
|
-
# Known long option (plain, no dot-path)
|
|
912
|
-
raw_value = tok.value
|
|
913
|
-
# No inline `=` and option expects a value: consume the next :argument token
|
|
914
|
-
raw_value = shift_next_argument_token \
|
|
915
|
-
if !tok.has_value && !@declared_options[resolved_sym].types.eql?(Type::NONE)
|
|
916
|
-
dispatch_option(resolved_sym, raw_value)
|
|
917
|
-
else
|
|
918
|
-
Log.log.trace1 { "Unknown long option: #{tok.raw}".red }
|
|
919
|
-
deferred_tokens.push(tok)
|
|
920
|
-
end
|
|
921
|
-
end
|
|
922
|
-
@current_option_args_offset = nil
|
|
923
|
-
@current_parse_idx = nil
|
|
924
|
-
Log.log.trace1('After parse')
|
|
925
|
-
Log.log.trace1 { "deferred: #{deferred_tokens}" }
|
|
926
|
-
# @argv_tokens now contains only: remaining Arguments + deferred Options (already in correct order).
|
|
927
|
-
# Re-insert deferred Options at their original positions by rebuilding from @initial_argv_tokens.
|
|
928
|
-
deferred_ids = deferred_tokens.map(&:object_id).to_set
|
|
929
|
-
remaining_arg_ids = @argv_tokens.grep(Argument).map(&:object_id).to_set
|
|
930
|
-
injected = @argv_tokens.grep(Argument).reject { |t| @initial_argv_tokens.include?(t) }
|
|
931
|
-
@argv_tokens = @initial_argv_tokens.select do |t|
|
|
932
|
-
(t.is_a?(Option) && deferred_ids.include?(t.object_id)) ||
|
|
933
|
-
(t.is_a?(Argument) && remaining_arg_ids.include?(t.object_id))
|
|
934
|
-
end
|
|
935
|
-
# Prepend arguments injected at runtime (e.g. via unshift_next_argument) not in @initial_argv_tokens.
|
|
936
|
-
@argv_tokens.unshift(*injected)
|
|
937
|
-
end
|
|
938
|
-
|
|
939
|
-
# Extract raw string tokens from @argv_tokens (Argument objects) according to `multiple`.
|
|
940
|
-
# Consumed Argument tokens are removed from @argv_tokens.
|
|
941
|
-
# @param multiple [false, true, String] consumption mode:
|
|
942
|
-
# false — consume exactly one token
|
|
943
|
-
# true — consume all remaining tokens
|
|
944
|
-
# String — consume up to (but not including) the marker token, or all if absent
|
|
945
|
-
# @return [Array<String>] consumed raw token strings
|
|
946
|
-
def extract_argument_tokens(multiple)
|
|
947
|
-
arg_tokens = @argv_tokens.grep(Argument)
|
|
948
|
-
case multiple
|
|
949
|
-
when false
|
|
950
|
-
tok = arg_tokens.first
|
|
951
|
-
@argv_tokens.delete(tok)
|
|
952
|
-
[tok.value]
|
|
953
|
-
when true
|
|
954
|
-
arg_tokens.each { |t| @argv_tokens.delete(t) }
|
|
955
|
-
arg_tokens.map(&:value)
|
|
956
|
-
when String
|
|
957
|
-
idx = arg_tokens.index { |t| t.value.eql?(multiple) }
|
|
958
|
-
consumed = idx ? arg_tokens[0, idx] : arg_tokens
|
|
959
|
-
marker = idx ? arg_tokens[idx] : nil
|
|
960
|
-
(consumed + [marker].compact).each { |t| @argv_tokens.delete(t) }
|
|
961
|
-
consumed.map(&:value)
|
|
962
|
-
else Aspera.error_unexpected_value(multiple) { 'multiple' }
|
|
963
|
-
end
|
|
405
|
+
@parse_needed = false
|
|
406
|
+
apply_pending_values
|
|
407
|
+
@command_line.pending_option_tokens.each { |tok| apply_option_token(tok) }
|
|
408
|
+
# Presets loaded by a command line option (e.g. `-P`)
|
|
409
|
+
apply_pending_values
|
|
410
|
+
Log.log.trace1 { "unprocessed options: #{@command_line.pending_options}" }
|
|
964
411
|
end
|
|
965
412
|
|
|
966
|
-
#
|
|
967
|
-
#
|
|
968
|
-
# Raises BadArgument when the value is an Integer on a STRING validation (coerced upstream).
|
|
969
|
-
# Raises BadArgument when the value's type is not in the validation list.
|
|
970
|
-
# @param value [Object] the value to validate
|
|
971
|
-
# @param validation [Array<Class>] accepted types
|
|
972
|
-
# @param descr [String] argument description (for error messages)
|
|
973
|
-
# @param schema [String, nil] schema path for SchemaRequest
|
|
974
|
-
def validate_argument(value, validation:, descr:, schema:)
|
|
975
|
-
raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(HELP)
|
|
976
|
-
raise Cli::BadArgument,
|
|
977
|
-
"Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
|
|
978
|
-
unless validation.any? { |t| value.is_a?(t) }
|
|
979
|
-
end
|
|
980
|
-
|
|
981
|
-
# Prompt user for console input
|
|
982
|
-
# @param prompt [String] prompt string to display
|
|
983
|
-
# @param sensitive [Boolean] whether to hide typed input
|
|
984
|
-
# @return [String] user input stripped of trailing newline
|
|
985
|
-
def prompt_user_input(prompt, sensitive: false)
|
|
986
|
-
return $stdin.getpass("#{prompt}> ") if sensitive
|
|
987
|
-
print("#{prompt}> ")
|
|
988
|
-
line = $stdin.gets
|
|
989
|
-
Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
|
|
990
|
-
line.chomp
|
|
991
|
-
end
|
|
992
|
-
|
|
993
|
-
# prompt user for input in a list of symbols
|
|
994
|
-
# @param prompt [String] prompt to display
|
|
995
|
-
# @param sym_list [Array] list of symbols to select from
|
|
996
|
-
# @return [Symbol] selected symbol
|
|
997
|
-
def prompt_user_input_in_list(prompt, sym_list)
|
|
998
|
-
loop do
|
|
999
|
-
input = prompt_user_input(prompt).to_sym
|
|
1000
|
-
if sym_list.any? { |a| a.eql?(input) }
|
|
1001
|
-
return input
|
|
1002
|
-
else
|
|
1003
|
-
$stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
|
|
1004
|
-
end
|
|
1005
|
-
end
|
|
1006
|
-
end
|
|
1007
|
-
|
|
1008
|
-
# Prompt user for input in a list of symbols
|
|
1009
|
-
# @param descr [String] description for help
|
|
1010
|
-
# @param check_option [Boolean] Check attributes of option with name=descr
|
|
413
|
+
# Prompt user for missing option or argument, or raise if not interactive
|
|
414
|
+
# @param descr [String] option name, or argument description
|
|
1011
415
|
# @param multiple [Boolean, String] `true` if multiple values expected
|
|
1012
|
-
# @param accept_list [Array<Symbol>,
|
|
416
|
+
# @param accept_list [Array<Symbol>, nil] List of expected values
|
|
417
|
+
# @param aliases [Hash, nil] aliases of values, for error message
|
|
418
|
+
# @param schema [String, nil] schema of value, for error message
|
|
1013
419
|
# @return [String] user input
|
|
1014
|
-
def get_interactive(descr,
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
default_prompt = "#{what}: #{descr}"
|
|
420
|
+
def get_interactive(descr, multiple: false, accept_list: nil, aliases: nil, schema: nil)
|
|
421
|
+
option = @registry.options[descr.to_sym]
|
|
422
|
+
default_prompt = "#{option ? 'option' : 'argument'}: #{descr}"
|
|
1018
423
|
if !@ask_missing_mandatory
|
|
1019
424
|
message = "Missing #{default_prompt}"
|
|
1020
425
|
message = self.class.multi_choice_assert_msg(message, accept_list, aliases: aliases) if accept_list
|
|
1021
|
-
message += "\n#{TerminalFormatter
|
|
426
|
+
message += "\n#{TerminalFormatter.hint}Give `#{SchemaRequest::KEYWORD}` as argument to retrieve the schema of the missing argument." if schema
|
|
1022
427
|
raise Cli::MissingArgument, message
|
|
1023
428
|
end
|
|
1024
|
-
#
|
|
429
|
+
# Ask interactively
|
|
1025
430
|
result = []
|
|
1026
431
|
puts(' (one per line, end with empty line)') if multiple
|
|
1027
432
|
loop do
|
|
1028
433
|
prompt = default_prompt
|
|
1029
434
|
prompt = "#{accept_list.join(' ')}\n#{default_prompt}" if accept_list
|
|
1030
|
-
entry = prompt_user_input(prompt, sensitive:
|
|
435
|
+
entry = prompt_user_input(prompt, sensitive: option&.sensitive)
|
|
1031
436
|
break if entry.empty? && multiple
|
|
1032
437
|
entry = ExtendedValue.instance.evaluate(entry, context: 'interactive input')
|
|
1033
438
|
entry = self.class.get_from_list(entry, descr, accept_list) if accept_list
|
|
@@ -1038,30 +443,19 @@ module Aspera
|
|
|
1038
443
|
end
|
|
1039
444
|
|
|
1040
445
|
# Read remaining args and build an `Array` or `Hash`
|
|
1041
|
-
#
|
|
446
|
+
# When used in an option value, only positional arguments after the option are used.
|
|
447
|
+
# @param end_marker [String] Argument to `@:` extended value
|
|
1042
448
|
# @return [Hash, Array] Object representing dot-path values
|
|
1043
449
|
def args_as_extended(end_marker)
|
|
1044
|
-
# This extended value does not take args (`@:`)
|
|
1045
|
-
# ExtendedValue.assert_no_value(end_marker, :p)
|
|
1046
450
|
end_marker = SpecialValues::EOA if end_marker.empty?
|
|
1047
|
-
# When called from an option value, skip positional args that appear before the option in argv.
|
|
1048
|
-
# @current_option_args_offset is set by parse_options! to the number of args in
|
|
1049
|
-
# @unprocessed_cmd_line_arguments that preceded this option; nil when called from a positional context.
|
|
1050
|
-
skip_count = @current_option_args_offset || 0
|
|
1051
|
-
# Skip leading positional args that precede this option in original argv order.
|
|
1052
|
-
# Grab the first `skip_count` Argument tokens and temporarily remove them.
|
|
1053
|
-
arg_tokens = @argv_tokens.grep(Argument)
|
|
1054
|
-
skipped_tokens = skip_count.positive? ? arg_tokens.first(skip_count) : []
|
|
1055
|
-
skipped_tokens.each { |t| @argv_tokens.delete(t) }
|
|
1056
|
-
Log.log.trace1 { "args_as_extended: skipping #{skipped_tokens.length} args before option: #{skipped_tokens.map(&:value)}" } unless skipped_tokens.empty?
|
|
1057
451
|
result = nil
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
452
|
+
@command_line.with_arguments_after_current_option do
|
|
453
|
+
get_next_argument('args', multiple: end_marker).each do |argument|
|
|
454
|
+
Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
|
|
455
|
+
path, value = argument.split(Option::VALUE_SEP, 2)
|
|
456
|
+
result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
|
|
457
|
+
end
|
|
1062
458
|
end
|
|
1063
|
-
# Restore skipped tokens so they remain available for command dispatching
|
|
1064
|
-
skipped_tokens.reverse_each { |t| @argv_tokens.unshift(t) }
|
|
1065
459
|
result
|
|
1066
460
|
end
|
|
1067
461
|
|
|
@@ -1071,15 +465,14 @@ module Aspera
|
|
|
1071
465
|
def help_text(banner: nil)
|
|
1072
466
|
rows = []
|
|
1073
467
|
current_group = nil
|
|
1074
|
-
@
|
|
468
|
+
@registry.options.each do |sym, opt|
|
|
1075
469
|
if opt.group != current_group
|
|
1076
470
|
current_group = opt.group
|
|
1077
471
|
rows << [{value: "OPTIONS: #{current_group}", colspan: 2}]
|
|
1078
472
|
end
|
|
1079
|
-
|
|
1080
|
-
short_part = short_char ? "-#{short_char}, " : ' '
|
|
473
|
+
short_part = opt.short ? "-#{opt.short}, " : ' '
|
|
1081
474
|
flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
|
|
1082
|
-
desc = opt.deprecation ? "#{opt.description} (
|
|
475
|
+
desc = opt.deprecation ? "#{opt.description} (#{opt.deprecation})" : opt.description
|
|
1083
476
|
rows << [flag, desc]
|
|
1084
477
|
end
|
|
1085
478
|
table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
|
|
@@ -1099,14 +492,69 @@ module Aspera
|
|
|
1099
492
|
b.remove_horizontals
|
|
1100
493
|
end.freeze
|
|
1101
494
|
|
|
495
|
+
# Parse if options were declared or presets added since last parse
|
|
496
|
+
def ensure_parsed
|
|
497
|
+
parse_options! if @parse_needed
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Add a type to the message if not special types
|
|
501
|
+
# @param types [Array<Class>] types to add
|
|
502
|
+
# @return [String] Types if relevant
|
|
503
|
+
def add_types_info(types)
|
|
504
|
+
return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
|
|
505
|
+
" (#{types.map(&:name).join(', ')})"
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Consume and evaluate positional arguments
|
|
509
|
+
# @return [Object, Array] one value, or list if `multiple`
|
|
510
|
+
def read_arguments(descr, multiple:, validation:, accept_list:, aliases:)
|
|
511
|
+
values = @command_line.shift_arguments(multiple)
|
|
512
|
+
values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
|
|
513
|
+
# If expecting list and only one arg of type array : it is the list
|
|
514
|
+
values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
|
|
515
|
+
if accept_list
|
|
516
|
+
allowed_values = accept_list + (aliases&.keys || [])
|
|
517
|
+
values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
|
|
518
|
+
end
|
|
519
|
+
multiple ? values : values.first
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Convert argument to expected type, when unambiguous
|
|
523
|
+
# @param value [Object] argument value
|
|
524
|
+
# @param validation [Array<Class>, nil] accepted types
|
|
525
|
+
# @return [Object] converted value
|
|
526
|
+
def convert_argument(value, validation)
|
|
527
|
+
# if value comes from JSON/YAML, it may come as Integer
|
|
528
|
+
return value.to_s if value.is_a?(Integer) && validation.eql?(Type::STRING)
|
|
529
|
+
return value unless value.is_a?(String) && validation.eql?(Type::INTEGER)
|
|
530
|
+
Integer(value, exception: false).tap { |i| raise Cli::BadArgument, "Invalid integer: #{value}" if i.nil? }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Validate a single argument value.
|
|
534
|
+
# @param value [Object] the value to validate
|
|
535
|
+
# @param validation [Array<Class>] accepted types
|
|
536
|
+
# @param descr [String] argument description (for error messages)
|
|
537
|
+
# @param schema [String, nil] schema path for SchemaRequest and validation
|
|
538
|
+
# @raise [SchemaRequest] when the value is 'help' and validation includes Hash.
|
|
539
|
+
# @raise [BadArgument] when the value's type is not in the validation list.
|
|
540
|
+
# @raise [BadArgument] when the value does not match its schema.
|
|
541
|
+
def validate_argument(value, validation:, descr:, schema:)
|
|
542
|
+
raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(SchemaRequest::KEYWORD)
|
|
543
|
+
raise BadArgument,
|
|
544
|
+
"Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
|
|
545
|
+
unless validation.any? { |t| value.is_a?(t) }
|
|
546
|
+
errors = Schema::Validator.instance.errors(value, schema) if schema && (value.is_a?(Hash) || value.is_a?(Array))
|
|
547
|
+
raise BadArgument, "Argument #{descr}: #{errors.join('; ')} (give `#{SchemaRequest::KEYWORD}` as argument for schema)" unless errors.nil? || errors.empty?
|
|
548
|
+
end
|
|
549
|
+
|
|
1102
550
|
# @param opt [OptionValue] option descriptor
|
|
1103
|
-
# @return [String, nil] placeholder shown in flag column
|
|
551
|
+
# @return [String, nil] placeholder shown in flag column, or nil for flags
|
|
1104
552
|
def option_display_value(opt)
|
|
1105
|
-
case opt.
|
|
1106
|
-
when
|
|
1107
|
-
when
|
|
1108
|
-
when
|
|
1109
|
-
when
|
|
553
|
+
case opt.kind
|
|
554
|
+
when :flag then nil
|
|
555
|
+
when :boolean then 'yes|no'
|
|
556
|
+
when :integer then 'INT'
|
|
557
|
+
when :enum
|
|
1110
558
|
opt.values&.any? && opt.values.length <= 4 ? opt.values.join('|') : 'ENUM'
|
|
1111
559
|
else
|
|
1112
560
|
if opt.types&.include?(Hash) || !opt.schema.nil?
|
|
@@ -1119,24 +567,71 @@ module Aspera
|
|
|
1119
567
|
end
|
|
1120
568
|
end
|
|
1121
569
|
|
|
1122
|
-
#
|
|
1123
|
-
# @param
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
if
|
|
1128
|
-
|
|
570
|
+
# Apply a command line option token if its option is declared, else keep it for later.
|
|
571
|
+
# @param tok [Option] option token
|
|
572
|
+
def apply_option_token(tok)
|
|
573
|
+
return if tok.consumed
|
|
574
|
+
option = tok.short_char ? @registry.by_short(tok.short_char) : resolve_long_name(tok)
|
|
575
|
+
return if option.nil?
|
|
576
|
+
if option.flag?
|
|
577
|
+
flags = flag_options(tok, option)
|
|
578
|
+
return if flags.nil?
|
|
579
|
+
@command_line.consume(tok, takes_value: false)
|
|
580
|
+
flags.each { |f| f.block.call }
|
|
1129
581
|
else
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
582
|
+
value = @command_line.consume(tok, takes_value: true)
|
|
583
|
+
@command_line.with_current_option(tok) do
|
|
584
|
+
if tok.dot_path.nil?
|
|
585
|
+
option.assign_value(value, source: :cmdline)
|
|
586
|
+
else
|
|
587
|
+
# Fill a copy of the current value: the current value is not modified in place (`on_set` callback already received it),
|
|
588
|
+
# and the result is complete (e.g. `--opt.0=a --opt.1=b`): not merged again
|
|
589
|
+
current = copy_containers(option.value(log: false))
|
|
590
|
+
value = DotContainer.dotted_to_container(tok.dot_path, Parser.smart_convert(value), current)
|
|
591
|
+
option.assign_value(value, source: :cmdline, merge: false)
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Copy nested `Hash` and `Array` containers, keep other values as-is (e.g. a `Proc` cannot be marshalled)
|
|
598
|
+
# @param value [Object] value to copy
|
|
599
|
+
# @return [Object] copy
|
|
600
|
+
def copy_containers(value)
|
|
601
|
+
case value
|
|
602
|
+
when Hash then value.transform_values { |v| copy_containers(v) }
|
|
603
|
+
when Array then value.map { |v| copy_containers(v) }
|
|
604
|
+
else value
|
|
1137
605
|
end
|
|
1138
606
|
end
|
|
1139
607
|
|
|
608
|
+
# Flags to execute for a flag token: `--help`, `-h`, or combined `-hN`
|
|
609
|
+
# @param tok [Option] flag token
|
|
610
|
+
# @param option [OptionValue] declared flag option
|
|
611
|
+
# @return [Array<OptionValue>, nil] flags, or `nil` if combined flags are not all declared yet
|
|
612
|
+
# @raise [BadArgument] if a value is given
|
|
613
|
+
def flag_options(tok, option)
|
|
614
|
+
if tok.short_char.nil?
|
|
615
|
+
Aspera.assert(!tok.inline? && tok.dot_path.nil?, type: BadArgument) { "Option #{self.class.option_name_to_line(option.option)} does not take a value" }
|
|
616
|
+
return [option]
|
|
617
|
+
end
|
|
618
|
+
flags = [option, *tok.inline_value.to_s.chars.map { |c| @registry.by_short(c) }]
|
|
619
|
+
return if flags.any?(&:nil?)
|
|
620
|
+
Aspera.assert(flags.all?(&:flag?), type: BadArgument) { "Option -#{tok.short_char} does not take a value: #{tok.raw}" }
|
|
621
|
+
flags
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Resolve long option name: exact match, or unique abbreviation (recorded on token).
|
|
625
|
+
# @param tok [Option] long option token
|
|
626
|
+
# @return [OptionValue, nil] declared option, or `nil` if not declared yet
|
|
627
|
+
# @raise [BadArgument] if abbreviation is ambiguous
|
|
628
|
+
def resolve_long_name(tok)
|
|
629
|
+
# No abbreviation with dotted notation
|
|
630
|
+
option = @registry.by_long(tok.name, allow_abbreviation: tok.dot_path.nil?)
|
|
631
|
+
tok.abbreviation_of = option.option if !option.nil? && !option.option.to_s.eql?(tok.name)
|
|
632
|
+
option
|
|
633
|
+
end
|
|
634
|
+
|
|
1140
635
|
# Generate command line option string from option symbol
|
|
1141
636
|
# @param symbol [Symbol] option name
|
|
1142
637
|
# @param opt_val [String, nil] optional value placeholder
|
|
@@ -1146,72 +641,36 @@ module Aspera
|
|
|
1146
641
|
opt_val.nil? ? result : "#{result}#{Option::VALUE_SEP}#{opt_val}"
|
|
1147
642
|
end
|
|
1148
643
|
|
|
1149
|
-
#
|
|
1150
|
-
#
|
|
1151
|
-
# @param
|
|
1152
|
-
# @param
|
|
1153
|
-
# @
|
|
1154
|
-
def
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
end
|
|
1162
|
-
end
|
|
1163
|
-
|
|
1164
|
-
#
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
# @return [Hash{Symbol => Object}] pairs whose keys were not yet declared (deferred to next round)
|
|
1172
|
-
def consume_option_pairs(option_pairs, where)
|
|
1173
|
-
Log.log.trace1 { "consume_option_pairs: #{where}" }
|
|
1174
|
-
remaining = {}
|
|
1175
|
-
option_pairs.each do |k, v|
|
|
1176
|
-
if @declared_options.key?(k)
|
|
1177
|
-
set_option(k, v, where: where)
|
|
1178
|
-
else
|
|
1179
|
-
Log.log.trace1 { "unprocessed: #{k}: #{v}" }
|
|
1180
|
-
remaining[k] = v
|
|
644
|
+
# Keep value from preset or env until option is declared
|
|
645
|
+
# @param option_symbol [Symbol] option name
|
|
646
|
+
# @param value [Object] value
|
|
647
|
+
# @param source [Symbol] `OptionSource`
|
|
648
|
+
# @param replace [Boolean] replace a pending value from same source
|
|
649
|
+
def add_pending_value(option_symbol, value, source, replace:)
|
|
650
|
+
entries = (@pending_values[option_symbol] ||= [])
|
|
651
|
+
index = entries.index { |e| e[:source].eql?(source) }
|
|
652
|
+
if index.nil?
|
|
653
|
+
entries.push({value: value, source: source})
|
|
654
|
+
elsif replace
|
|
655
|
+
entries[index] = {value: value, source: source}
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Apply pending values (presets, env) of declared options, lowest priority first
|
|
660
|
+
def apply_pending_values
|
|
661
|
+
ready = @pending_values.select { |k, _| @registry.declared?(k) }
|
|
662
|
+
ready.each_key { |k| @pending_values.delete(k) }
|
|
663
|
+
ready.each do |option_symbol, entries|
|
|
664
|
+
entries.sort_by { |e| OptionSource.priority(e[:source]) }.each do |e|
|
|
665
|
+
set_option(option_symbol, e[:value], source: e[:source])
|
|
1181
666
|
end
|
|
1182
667
|
end
|
|
1183
|
-
remaining
|
|
1184
|
-
end
|
|
1185
|
-
|
|
1186
|
-
# Consume the Argument token immediately following the current option in @argv_tokens.
|
|
1187
|
-
# After delete_at(@current_parse_idx), the next token is at @argv_tokens[@current_parse_idx].
|
|
1188
|
-
# @return [String, nil] the consumed argument value, or nil if the next token is not an Argument
|
|
1189
|
-
def shift_next_argument_token
|
|
1190
|
-
pos = @current_parse_idx || 0
|
|
1191
|
-
return unless @argv_tokens[pos]&.is_a?(Argument)
|
|
1192
|
-
|
|
1193
|
-
@argv_tokens.delete_at(pos).value
|
|
1194
|
-
end
|
|
1195
|
-
|
|
1196
|
-
# @return [Array<String>] values of all pending positional Argument tokens
|
|
1197
|
-
def pending_arguments
|
|
1198
|
-
@argv_tokens.grep(Argument).map(&:value)
|
|
1199
|
-
end
|
|
1200
|
-
|
|
1201
|
-
# @return [Array<String>] raw strings of all pending Option tokens
|
|
1202
|
-
def pending_options
|
|
1203
|
-
@argv_tokens.grep(Option).map(&:raw)
|
|
1204
668
|
end
|
|
1205
669
|
|
|
1206
|
-
# when this is alone, this stops option processing (same characters as Option::PREFIX but distinct semantics)
|
|
1207
|
-
OPTIONS_STOP = '--'
|
|
1208
|
-
SOURCE_USER = 'cmdline' # cspell:disable-line
|
|
1209
670
|
# Percent selector: select by this field for this value
|
|
1210
671
|
REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/
|
|
1211
|
-
# Ask for schema of Extended value
|
|
1212
|
-
HELP = 'help'
|
|
1213
672
|
|
|
1214
|
-
private_constant :
|
|
673
|
+
private_constant :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
|
|
1215
674
|
end
|
|
1216
675
|
end
|
|
1217
676
|
end
|