aspera-cli 4.26.2 → 4.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +310 -21
- data/CONTRIBUTING.md +99 -14
- data/TODO.md +50 -0
- data/bin/ascli +3 -3
- data/docs/README.md +10977 -0
- data/docs/test-mcp-with-ai.md +202 -0
- data/lib/aspera/agent/base.rb +29 -7
- data/lib/aspera/agent/connect.rb +58 -21
- data/lib/aspera/agent/desktop.rb +53 -18
- data/lib/aspera/agent/direct.rb +71 -40
- data/lib/aspera/agent/factory.rb +3 -3
- data/lib/aspera/agent/httpgw.rb +55 -14
- data/lib/aspera/agent/node.rb +34 -2
- data/lib/aspera/agent/transferd.rb +48 -12
- data/lib/aspera/api/alee.rb +1 -1
- data/lib/aspera/api/aoc.rb +42 -44
- data/lib/aspera/api/cos_node.rb +10 -9
- data/lib/aspera/api/faspex.rb +16 -15
- data/lib/aspera/api/httpgw.rb +23 -23
- data/lib/aspera/api/node.rb +51 -70
- data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
- data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
- data/lib/aspera/ascmd.rb +32 -30
- data/lib/aspera/ascp/installation.rb +75 -69
- data/lib/aspera/ascp/management.rb +7 -8
- data/lib/aspera/assert.rb +12 -13
- data/lib/aspera/cli/ascp_actions.rb +155 -0
- data/lib/aspera/cli/async_transfer_store.rb +125 -0
- data/lib/aspera/cli/bootstrapper.rb +197 -0
- data/lib/aspera/cli/command_registry.rb +145 -0
- data/lib/aspera/cli/command_spec.rb +141 -0
- data/lib/aspera/cli/context.rb +19 -6
- data/lib/aspera/cli/error.rb +16 -0
- data/lib/aspera/cli/extended_value.rb +27 -26
- data/lib/aspera/cli/formatter.rb +95 -59
- data/lib/aspera/cli/gem_checker.rb +65 -0
- data/lib/aspera/cli/hints.rb +3 -3
- data/lib/aspera/cli/http.rb +74 -30
- data/lib/aspera/cli/info.rb +2 -0
- data/lib/aspera/cli/mailer.rb +97 -0
- data/lib/aspera/cli/mcp_tool.rb +225 -0
- data/lib/aspera/cli/option_declarator.rb +77 -0
- data/lib/aspera/cli/options.schema.yaml +634 -7
- data/lib/aspera/cli/parser.rb +1217 -0
- data/lib/aspera/cli/plugins/alee.rb +20 -22
- data/lib/aspera/cli/plugins/aoc.rb +1178 -881
- data/lib/aspera/cli/plugins/ats.rb +205 -162
- data/lib/aspera/cli/plugins/base.rb +703 -175
- data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
- data/lib/aspera/cli/plugins/config.rb +434 -757
- data/lib/aspera/cli/plugins/console.rb +107 -65
- data/lib/aspera/cli/plugins/cos.rb +46 -33
- data/lib/aspera/cli/plugins/factory.rb +6 -6
- data/lib/aspera/cli/plugins/faspex5.rb +627 -388
- data/lib/aspera/cli/plugins/faspio.rb +54 -51
- data/lib/aspera/cli/plugins/httpgw.rb +18 -25
- data/lib/aspera/cli/plugins/mcp.rb +279 -0
- data/lib/aspera/cli/plugins/node.rb +988 -840
- data/lib/aspera/cli/plugins/oauth.rb +7 -10
- data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
- data/lib/aspera/cli/plugins/preview.rb +195 -157
- data/lib/aspera/cli/plugins/server.rb +141 -91
- data/lib/aspera/cli/plugins/shares.rb +343 -110
- data/lib/aspera/cli/preset_actions.rb +159 -0
- data/lib/aspera/cli/preset_manager.rb +82 -39
- data/lib/aspera/cli/result.rb +78 -26
- data/lib/aspera/cli/runner.rb +323 -153
- data/lib/aspera/cli/secret_finder.rb +40 -0
- data/lib/aspera/cli/special_values.rb +1 -0
- data/lib/aspera/cli/sync_actions.rb +89 -64
- data/lib/aspera/cli/terminal_formatter.rb +3 -3
- data/lib/aspera/cli/transfer_actions.rb +90 -0
- data/lib/aspera/cli/transfer_agent.rb +129 -64
- data/lib/aspera/cli/transfer_progress.rb +9 -9
- data/lib/aspera/cli/vault_manager.rb +79 -0
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +28 -30
- data/lib/aspera/colors.rb +3 -3
- data/lib/aspera/command_line_builder.rb +34 -34
- data/lib/aspera/command_line_converter.rb +1 -1
- data/lib/aspera/coverage.rb +1 -2
- data/lib/aspera/data_repository.rb +2 -1
- data/lib/aspera/dot_container.rb +12 -12
- data/lib/aspera/environment.rb +30 -23
- data/lib/aspera/exec_spec.rb +13 -0
- data/lib/aspera/faspex_gw.rb +5 -5
- data/lib/aspera/faspex_postproc.rb +16 -10
- data/lib/aspera/graphql.rb +37 -0
- data/lib/aspera/hash_ext.rb +8 -2
- data/lib/aspera/json_rpc/client.rb +62 -0
- data/lib/aspera/json_rpc/version.rb +7 -0
- data/lib/aspera/keychain/base.rb +10 -3
- data/lib/aspera/keychain/encrypted_hash.rb +92 -17
- data/lib/aspera/keychain/factory.rb +20 -8
- data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
- data/lib/aspera/keychain/macos_security.rb +23 -25
- data/lib/aspera/keychain/one_password_api.rb +86 -0
- data/lib/aspera/keychain/one_password_base.rb +34 -0
- data/lib/aspera/keychain/one_password_cli.rb +98 -0
- data/lib/aspera/link_header.rb +82 -0
- data/lib/aspera/log.rb +29 -15
- data/lib/aspera/markdown.rb +90 -7
- data/lib/aspera/nagios.rb +8 -8
- data/lib/aspera/node_simulator.rb +35 -27
- data/lib/aspera/oauth/base.rb +9 -10
- data/lib/aspera/oauth/boot.rb +5 -5
- data/lib/aspera/oauth/factory.rb +12 -10
- data/lib/aspera/oauth/jwt.rb +9 -11
- data/lib/aspera/oauth/web.rb +6 -6
- data/lib/aspera/persistency_action_once.rb +12 -12
- data/lib/aspera/persistency_folder.rb +18 -11
- data/lib/aspera/preview/file_types.rb +5 -5
- data/lib/aspera/preview/generator.rb +52 -49
- data/lib/aspera/preview/options.rb +3 -2
- data/lib/aspera/preview/terminal.rb +5 -5
- data/lib/aspera/preview/utils.rb +66 -19
- data/lib/aspera/products/connect.rb +2 -2
- data/lib/aspera/products/desktop.rb +1 -1
- data/lib/aspera/products/other.rb +3 -3
- data/lib/aspera/products/transferd.rb +4 -4
- data/lib/aspera/proxy_auto_config.rb +15 -14
- data/lib/aspera/rest.rb +159 -151
- data/lib/aspera/rest_error_analyzer.rb +6 -6
- data/lib/aspera/rest_errors_aspera.rb +0 -10
- data/lib/aspera/rest_list.rb +11 -10
- data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
- data/lib/aspera/schema/documentation.rb +65 -28
- data/lib/aspera/schema/reader.rb +136 -13
- data/lib/aspera/schema/registry.rb +60 -8
- data/lib/aspera/secret_hider.rb +12 -7
- data/lib/aspera/ssh.rb +64 -31
- data/lib/aspera/ssl.rb +5 -5
- data/lib/aspera/sync/conf.schema.yaml +2 -2
- data/lib/aspera/sync/database.rb +2 -2
- data/lib/aspera/sync/operations.rb +22 -25
- data/lib/aspera/temp_file_manager.rb +25 -5
- data/lib/aspera/timer_limiter.rb +1 -1
- data/lib/aspera/transfer/faux_file.rb +24 -11
- data/lib/aspera/transfer/parameters.rb +36 -34
- data/lib/aspera/transfer/result.rb +74 -0
- data/lib/aspera/transfer/resumer.rb +10 -10
- data/lib/aspera/transfer/spec.rb +18 -1
- data/lib/aspera/transfer/spec.schema.yaml +12 -3
- data/lib/aspera/uri_reader.rb +52 -15
- data/lib/aspera/web_auth.rb +8 -7
- data/lib/aspera/web_server_simple.rb +15 -12
- data/lib/aspera/yaml.rb +5 -4
- data.tar.gz.sig +0 -0
- metadata +34 -6
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/manager.rb +0 -766
- data/lib/aspera/cli/plugins/faspex.rb +0 -539
- data/lib/aspera/json_rpc.rb +0 -52
- data/lib/aspera/transfer/uri.rb +0 -56
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/cli/preset_manager'
|
|
4
|
+
require 'aspera/cli/extended_value'
|
|
5
|
+
require 'aspera/cli/error'
|
|
6
|
+
require 'aspera/cli/special_values'
|
|
7
|
+
require 'aspera/cli/terminal_formatter'
|
|
8
|
+
require 'aspera/colors'
|
|
9
|
+
require 'aspera/secret_hider'
|
|
10
|
+
require 'aspera/log'
|
|
11
|
+
require 'aspera/assert'
|
|
12
|
+
require 'aspera/dot_container'
|
|
13
|
+
require 'aspera/schema/registry'
|
|
14
|
+
require 'io/console'
|
|
15
|
+
require 'terminal-table'
|
|
16
|
+
|
|
17
|
+
module Aspera
|
|
18
|
+
module Cli
|
|
19
|
+
# Exception raised when schema is asked (`help`)
|
|
20
|
+
class SchemaRequest < Error
|
|
21
|
+
# @return [String, nil] path to schema file
|
|
22
|
+
attr_reader :path
|
|
23
|
+
|
|
24
|
+
# @param type [Symbol] :argument or :option
|
|
25
|
+
# @param name [String] name of the option/argument
|
|
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
|
|
406
|
+
class Parser
|
|
407
|
+
class << self
|
|
408
|
+
# Find shortened string value in allowed symbol list
|
|
409
|
+
# @param short_value [String] value or prefix to find
|
|
410
|
+
# @param descr [String] description for error messages
|
|
411
|
+
# @param allowed_values [Array] list of allowed values
|
|
412
|
+
# @return [Symbol, Boolean] matched symbol or boolean value
|
|
413
|
+
def get_from_list(short_value, descr, allowed_values)
|
|
414
|
+
Aspera.assert_type(short_value, String)
|
|
415
|
+
# we accept shortcuts
|
|
416
|
+
matching_exact = allowed_values.select { |i| i.to_s.eql?(short_value) }
|
|
417
|
+
return matching_exact.first if matching_exact.length == 1
|
|
418
|
+
matching = allowed_values.select { |i| i.to_s.start_with?(short_value) }
|
|
419
|
+
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
|
+
Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
|
|
421
|
+
Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
|
|
422
|
+
return BoolValue.true?(matching.first) if allowed_values.eql?(BoolValue::ALL)
|
|
423
|
+
matching.first
|
|
424
|
+
end
|
|
425
|
+
|
|
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
|
+
# Generates error message with list of allowed values
|
|
435
|
+
# @param error_msg [String] Error message
|
|
436
|
+
# @param accept_list [Array<Symbol>] List of allowed values
|
|
437
|
+
# @param aliases [Hash{Symbol=>Symbol}, nil] alias→id map; used to annotate entries with their aliases
|
|
438
|
+
def multi_choice_assert_msg(error_msg, accept_list, aliases: nil)
|
|
439
|
+
# Build reverse map: id → [alias, ...] for annotation
|
|
440
|
+
reverse = aliases&.each_with_object({}) do |(ali, id), h|
|
|
441
|
+
(h[id] ||= []) << ali
|
|
442
|
+
end
|
|
443
|
+
lines = accept_list.map do |choice|
|
|
444
|
+
suffix = reverse&.key?(choice) ? " (alias: #{reverse[choice].join(', ')})" : ''
|
|
445
|
+
"- #{choice}#{suffix}"
|
|
446
|
+
end
|
|
447
|
+
[error_msg, 'Use:', *lines.sort].join("\n")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Change option name with dash to name with underscore
|
|
451
|
+
# @param name [String] option name with dash separators
|
|
452
|
+
# @return [String] option name with underscore separators
|
|
453
|
+
def option_line_to_name(name)
|
|
454
|
+
name.gsub(Option::NAME_SEP_LINE, Option::NAME_SEP_SYMBOL)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Convert option symbol to CLI line flag format
|
|
458
|
+
# @param name [Symbol, String] option name
|
|
459
|
+
# @return [String] option flag (e.g. "--option-name")
|
|
460
|
+
def option_name_to_line(name)
|
|
461
|
+
"#{Option::PREFIX}#{name.to_s.gsub(Option::NAME_SEP_SYMBOL, Option::NAME_SEP_LINE)}"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Parse percent-selector string into field name and value (extended value is parsed in value)
|
|
465
|
+
# @param identifier [String] identifier to parse
|
|
466
|
+
# @return [Hash{Symbol => String}, nil] `{field:,value:}` if identifier is a percent selector, else `nil`
|
|
467
|
+
def percent_selector(identifier)
|
|
468
|
+
Aspera.assert_type(identifier, String)
|
|
469
|
+
if (m = identifier.match(REGEX_LOOKUP_ID_BY_FIELD))
|
|
470
|
+
return {field: m[1], value: ExtendedValue.instance.evaluate(m[2], context: "percent selector: #{m[1]}")}
|
|
471
|
+
end
|
|
472
|
+
nil
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Using dotted hash notation, convert value to bool, int, float or extended value
|
|
476
|
+
# @param value [String] The value to convert to appropriate type
|
|
477
|
+
# @return [Boolean, Integer, Float, String, Array, Hash] the converted value
|
|
478
|
+
def smart_convert(value)
|
|
479
|
+
case value
|
|
480
|
+
when 'true' then true
|
|
481
|
+
when 'false' then false
|
|
482
|
+
else
|
|
483
|
+
Integer(value, exception: false) ||
|
|
484
|
+
Float(value, exception: false) ||
|
|
485
|
+
ExtendedValue.instance.evaluate(value, context: 'dotted expression')
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
attr_accessor :ask_missing_mandatory, :ask_missing_optional, :help_requested
|
|
491
|
+
attr_writer :fail_on_missing_mandatory
|
|
492
|
+
|
|
493
|
+
# @param program_name [String] Name of the program
|
|
494
|
+
# @param argv [Array<String>, nil] Command line arguments to parse
|
|
495
|
+
def initialize(program_name, argv = nil)
|
|
496
|
+
# Option descriptions: maps option symbol to its OptionValue descriptor
|
|
497
|
+
# @type [Hash{Symbol => OptionValue}]
|
|
498
|
+
@declared_options = {}
|
|
499
|
+
# do we ask missing options and arguments to user ?
|
|
500
|
+
@ask_missing_mandatory = false # STDIN.isatty
|
|
501
|
+
# ask optional options if not provided and in interactive
|
|
502
|
+
@ask_missing_optional = false
|
|
503
|
+
# get_option fails if a mandatory parameter is asked
|
|
504
|
+
@fail_on_missing_mandatory = true
|
|
505
|
+
# set to true when --help / -h is parsed
|
|
506
|
+
@help_requested = false
|
|
507
|
+
# options explicitly reset to nil from CLI (e.g. --opt=@none:); preset injection skips these
|
|
508
|
+
@explicitly_cleared = {}
|
|
509
|
+
# 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
|
+
env_prefix = program_name.upcase + Option::NAME_SEP_SYMBOL
|
|
517
|
+
ENV.each do |k, v|
|
|
518
|
+
@option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
|
|
519
|
+
end
|
|
520
|
+
Log.dump(:env, @option_pairs_env)
|
|
521
|
+
# Ordered list of all CLI tokens after `--` splitting.
|
|
522
|
+
# Single source of truth for CLI parsing — pending_arguments and pending_options are derived views.
|
|
523
|
+
# @type [Array<Option, Argument>]
|
|
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(', ')})"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Declare an option
|
|
563
|
+
# @param option_symbol [Symbol] option name
|
|
564
|
+
# @param description [String, nil] description for help; if nil, derived from schema
|
|
565
|
+
# @param short [String] short option name
|
|
566
|
+
# @param allowed [Object] Allowed values, see `OptionValue`.
|
|
567
|
+
# When `schema:` is provided:
|
|
568
|
+
# - Omit `allowed:` when the schema has a single type (`type: object/array`): it is inferred automatically.
|
|
569
|
+
# - Omit `allowed:` when the schema uses `oneOf`/`anyOf` and all branches are `object`: `Hash` is inferred.
|
|
570
|
+
# - Use `allowed: [Hash, String]` when the option additionally accepts a plain String shorthand;
|
|
571
|
+
# the schema then documents the Hash form and `=help` still shows it.
|
|
572
|
+
# @param default [Object] default value
|
|
573
|
+
# @param handler [Hash] handler for option value: keys: :o(object) and :m(method)
|
|
574
|
+
# @param deprecation [String] deprecation
|
|
575
|
+
# @param schema [String] schema path documenting the Hash form of this option
|
|
576
|
+
# @param block [Proc] Block to execute when option is found
|
|
577
|
+
def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
|
|
578
|
+
Aspera.assert_type(option_symbol, Symbol)
|
|
579
|
+
Aspera.assert(!@declared_options.key?(option_symbol)) { "#{option_symbol} already declared" }
|
|
580
|
+
Aspera.assert_type(handler, Hash) if handler
|
|
581
|
+
Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
|
|
582
|
+
option_attrs = @declared_options[option_symbol] = OptionValue.new(
|
|
583
|
+
option: option_symbol,
|
|
584
|
+
description: description,
|
|
585
|
+
allowed: allowed,
|
|
586
|
+
handler: handler,
|
|
587
|
+
deprecation: deprecation,
|
|
588
|
+
schema: schema
|
|
589
|
+
)
|
|
590
|
+
option_attrs.group = @current_group
|
|
591
|
+
description = option_attrs.description
|
|
592
|
+
Aspera.assert(!description.nil?) { "#{option_symbol}: no description and no schema to derive one from" }
|
|
593
|
+
Aspera.assert(description[-1] != '.') { "#{option_symbol} ends with dot" }
|
|
594
|
+
Aspera.assert(description[0] == description[0].upcase) { "#{option_symbol} description does not start with an uppercase" }
|
|
595
|
+
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, where: 'default', warn_deprecation: false) unless default.nil?
|
|
597
|
+
case option_attrs.types
|
|
598
|
+
when Type::ENUM, Type::BOOLEAN
|
|
599
|
+
# This option value must be a symbol (or array of symbols)
|
|
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
|
|
604
|
+
end
|
|
605
|
+
@short_options[short] = option_symbol unless short.nil?
|
|
606
|
+
Log.log.trace1 { "declare: #{option_symbol}, group: #{@current_group}, short: #{short}" }
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Set the current help section group name for subsequent declarations
|
|
610
|
+
# @param name [String] group name, shown as section header in help text
|
|
611
|
+
def group(name)
|
|
612
|
+
@current_group = name
|
|
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
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Low-level positional argument reader. Prefer +Base#resolve_argument+ from action methods.
|
|
625
|
+
# Direct calls from outside +Parser+ are legacy exceptions documented in ST12/ST13
|
|
626
|
+
# (mixins without DSL: sync_actions, ascp_actions; setup callbacks: aoc.rb).
|
|
627
|
+
# @api private
|
|
628
|
+
# @param descr [String] description for help
|
|
629
|
+
# @param mandatory [Boolean] `true`: raise error no more argument
|
|
630
|
+
# @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
|
|
631
|
+
# @param accept_list [Array<Symbol>, NilClass] list of allowed values
|
|
632
|
+
# @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
|
|
633
|
+
# @param aliases [Hash] map of aliases: key = alias, value = real value
|
|
634
|
+
# @param default [Object] default value
|
|
635
|
+
# @return [Object, Array, nil] one value, list or nil (if optional and no default)
|
|
636
|
+
def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil)
|
|
637
|
+
Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
|
|
638
|
+
Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
|
|
639
|
+
validation = Symbol unless accept_list.nil?
|
|
640
|
+
validation = [validation] unless validation.is_a?(Array) || validation.nil?
|
|
641
|
+
Aspera.assert_array_all(validation, Class) { 'validation' } unless validation.nil?
|
|
642
|
+
descr = "#{descr}#{add_types_info(validation)}"
|
|
643
|
+
result =
|
|
644
|
+
if !pending_arguments.empty?
|
|
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
|
|
655
|
+
elsif !default.nil? then default
|
|
656
|
+
elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, aliases: aliases, schema: schema)
|
|
657
|
+
end
|
|
658
|
+
Log.log.trace1 { "#{descr}=#{result}" }
|
|
659
|
+
result = aliases[result] if aliases&.key?(result)
|
|
660
|
+
# if value comes from JSON/YAML, it may come as Integer
|
|
661
|
+
result = result.to_s if result.is_a?(Integer) && validation&.eql?(Type::STRING)
|
|
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
|
|
672
|
+
result
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Resource identifier as positional parameter
|
|
676
|
+
#
|
|
677
|
+
# @param description [String] description of the identifier
|
|
678
|
+
# @param block [Proc] block to search for identifier based on attribute value
|
|
679
|
+
# @return [String, Array<String>] identifier or list of IDs (if `bulk` option is set)
|
|
680
|
+
# @yieldparam field [String] The field name from percent selector
|
|
681
|
+
# @yieldparam value [String] The value from percent selector
|
|
682
|
+
# @yieldreturn [String] Resolved identifier
|
|
683
|
+
def instance_identifier(description: 'identifier', &block)
|
|
684
|
+
res_id = get_next_argument(description, multiple: get_option(:bulk))
|
|
685
|
+
# Can be an Array
|
|
686
|
+
if res_id.is_a?(String) && (m = Parser.percent_selector(res_id))
|
|
687
|
+
Aspera.assert(block_given?, type: Cli::BadArgument) { "Percent syntax for #{description} not supported in this context" }
|
|
688
|
+
res_id = yield(m[:field], m[:value])
|
|
689
|
+
end
|
|
690
|
+
res_id
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Get next positional command argument from accepted list
|
|
694
|
+
# @param command_list [Array<Symbol>] accepted command names
|
|
695
|
+
# @param aliases [Hash, nil] command aliases
|
|
696
|
+
# @return [Symbol] selected command
|
|
697
|
+
def get_next_command(command_list, aliases: nil); get_next_argument('command', accept_list: command_list, aliases: aliases); end
|
|
698
|
+
|
|
699
|
+
# Check whether an option has already been declared in this manager
|
|
700
|
+
# @param option_symbol [Symbol] name of the option
|
|
701
|
+
# @return [Boolean]
|
|
702
|
+
def option_declared?(option_symbol)
|
|
703
|
+
@declared_options.key?(option_symbol)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# @return [Hash{Symbol => OptionValue}] all declared options (read-only view)
|
|
707
|
+
attr_reader :declared_options
|
|
708
|
+
|
|
709
|
+
# Get an option definition by name
|
|
710
|
+
# @param option_symbol [Symbol] name of the option
|
|
711
|
+
# @return [OptionValue] Option definition
|
|
712
|
+
# @raise [Cli::BadArgument] if option not found
|
|
713
|
+
def option_def(option_symbol)
|
|
714
|
+
Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument) { "Unknown option: #{option_symbol}" }
|
|
715
|
+
@declared_options[option_symbol]
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Get an option value by name
|
|
719
|
+
# either return value or calls handler, can return nil
|
|
720
|
+
# ask interactively if requested/required
|
|
721
|
+
# @param option_symbol [Symbol] name of the option to retrieve
|
|
722
|
+
# @param mandatory [Boolean] if true, raise error if option not set
|
|
723
|
+
# @param schema [String, nil] contextual schema path override; when set, raises SchemaRequest
|
|
724
|
+
# if the option value is 'help' (used for --query whose schema depends on the current command)
|
|
725
|
+
def get_option(option_symbol, mandatory: false, schema: nil)
|
|
726
|
+
Aspera.assert_type(option_symbol, Symbol)
|
|
727
|
+
option_attrs = option_def(option_symbol)
|
|
728
|
+
result = option_attrs.value
|
|
729
|
+
# Contextual schema: raise SchemaRequest when value is 'help'
|
|
730
|
+
raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(HELP)
|
|
731
|
+
# Do not fail for manual generation if option mandatory but not set
|
|
732
|
+
return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
|
|
733
|
+
if result.nil?
|
|
734
|
+
if !@ask_missing_mandatory
|
|
735
|
+
Aspera.assert(!mandatory, type: Cli::BadArgument) { "Missing mandatory option: #{option_symbol}" }
|
|
736
|
+
elsif @ask_missing_optional || mandatory
|
|
737
|
+
# ask_missing_mandatory
|
|
738
|
+
result = get_interactive(option_symbol.to_s, check_option: true, accept_list: option_attrs.values, schema: option_attrs.schema)
|
|
739
|
+
set_option(option_symbol, result, where: 'interactive')
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
result
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Set an option value by name, either store value or call handler
|
|
746
|
+
# String is given to extended value
|
|
747
|
+
# @param option_symbol [Symbol] option name
|
|
748
|
+
# @param value [String] Value to set
|
|
749
|
+
# @param where [String] Where the value comes from
|
|
750
|
+
def set_option(option_symbol, value, where: 'code override', warn_deprecation: true)
|
|
751
|
+
Aspera.assert_type(option_symbol, Symbol)
|
|
752
|
+
option = option_def(option_symbol)
|
|
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)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# Set option to `nil`
|
|
761
|
+
# @param option_symbol [Symbol] option name
|
|
762
|
+
# @return [nil]
|
|
763
|
+
def clear_option(option_symbol)
|
|
764
|
+
Aspera.assert_type(option_symbol, Symbol)
|
|
765
|
+
option_def(option_symbol).clear
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# Bind (or re-bind) a runtime handler to an already-declared option.
|
|
769
|
+
# Called from plugin initialize() for Category C handlers whose target object
|
|
770
|
+
# (e.g. @gen_options) is created after class-load time.
|
|
771
|
+
# @param option_symbol [Symbol] name of the already-declared option
|
|
772
|
+
# @param object [Object] the target object for get/set delegation
|
|
773
|
+
# @param method [Symbol] accessor method name on object
|
|
774
|
+
# @return [nil]
|
|
775
|
+
def set_handler(option_symbol, object:, method:)
|
|
776
|
+
Aspera.assert_type(option_symbol, Symbol)
|
|
777
|
+
option_def(option_symbol).bind_handler(o: object, m: method)
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# Adds each of the keys of specified hash as an option
|
|
781
|
+
# @param preset_hash [Hash] Options to add
|
|
782
|
+
# @param where [String] Where the value comes from
|
|
783
|
+
# @param override [Boolean] Override if already present
|
|
784
|
+
def add_option_preset(preset_hash, where, override: true)
|
|
785
|
+
Aspera.assert_type(preset_hash, Hash)
|
|
786
|
+
Log.log.debug { "add_option_preset: #{preset_hash}, #{where}, #{override}" }
|
|
787
|
+
preset_hash.each do |k, v|
|
|
788
|
+
# Ignore comment/meta keys (e.g. _comment, _description)
|
|
789
|
+
next if k.to_s.start_with?(PresetManager::Key::META_PREFIX)
|
|
790
|
+
option_symbol = k.to_sym
|
|
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)
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Allows a plugin to add an argument as next argument to process
|
|
798
|
+
# @param argument [String] argument value to prepend
|
|
799
|
+
# @return [Array<Option, Argument>] updated tokens list
|
|
800
|
+
def unshift_next_argument(argument)
|
|
801
|
+
@argv_tokens.unshift(Argument.new(argument))
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Check if there are no pending positional arguments
|
|
805
|
+
# @return [Boolean] true if no pending positional arguments
|
|
806
|
+
def command_or_arg_empty?
|
|
807
|
+
pending_arguments.empty?
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Check for unprocessed options or arguments error messages
|
|
811
|
+
# @return [Array<String>] list of error messages for unprocessed tokens
|
|
812
|
+
def final_errors
|
|
813
|
+
result = []
|
|
814
|
+
result.push("unprocessed options: #{pending_options}") unless pending_options.empty?
|
|
815
|
+
result.push("unprocessed values: #{pending_arguments}") unless pending_arguments.empty?
|
|
816
|
+
result
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
# Get all original options on command line used to generate a config in config file
|
|
820
|
+
# @return [Hash] options as taken from config file and command line just before command execution
|
|
821
|
+
def unprocessed_options_with_value
|
|
822
|
+
result = {}
|
|
823
|
+
@initial_argv_tokens.each_with_index do |tok, idx|
|
|
824
|
+
next unless tok.is_a?(Option) && tok.short_char.nil?
|
|
825
|
+
# For space-separated form: value is the immediately following :argument token (if any)
|
|
826
|
+
value = tok.value || @initial_argv_tokens[idx + 1]&.then { |t| t.value if t.is_a?(Argument) }
|
|
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) }
|
|
834
|
+
end
|
|
835
|
+
result
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# @param only_defined [Boolean] if true, only return options that were defined
|
|
839
|
+
# @return [Hash] options as taken from config file and command line just before command execution
|
|
840
|
+
def known_options(only_defined: false)
|
|
841
|
+
result = {}
|
|
842
|
+
@declared_options.each_key do |option_symbol|
|
|
843
|
+
v = get_option(option_symbol)
|
|
844
|
+
result[option_symbol] = v unless only_defined && v.nil?
|
|
845
|
+
rescue => e
|
|
846
|
+
result[option_symbol] = e.to_s
|
|
847
|
+
end
|
|
848
|
+
result
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Removes already known options from the list
|
|
852
|
+
def parse_options!
|
|
853
|
+
Log.log.trace1('parse_options!'.red)
|
|
854
|
+
# First options from conf file
|
|
855
|
+
@option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
|
|
856
|
+
# Then, env var (to override)
|
|
857
|
+
@option_pairs_env = consume_option_pairs(@option_pairs_env, 'env')
|
|
858
|
+
# Then, command line override.
|
|
859
|
+
# Iterate @argv_tokens in order so that --opt val and -s val can consume the next argument
|
|
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
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# Validate and coerce a single argument value.
|
|
967
|
+
# Raises SchemaRequest when the value is 'help' and validation includes Hash.
|
|
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
|
|
1011
|
+
# @param multiple [Boolean, String] `true` if multiple values expected
|
|
1012
|
+
# @param accept_list [Array<Symbol>,NilClass] List of expected values
|
|
1013
|
+
# @return [String] user input
|
|
1014
|
+
def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, aliases: nil, schema: nil)
|
|
1015
|
+
option_attrs = @declared_options[descr.to_sym]
|
|
1016
|
+
what = option_attrs ? 'option' : 'argument'
|
|
1017
|
+
default_prompt = "#{what}: #{descr}"
|
|
1018
|
+
if !@ask_missing_mandatory
|
|
1019
|
+
message = "Missing #{default_prompt}"
|
|
1020
|
+
message = self.class.multi_choice_assert_msg(message, accept_list, aliases: aliases) if accept_list
|
|
1021
|
+
message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
|
|
1022
|
+
raise Cli::MissingArgument, message
|
|
1023
|
+
end
|
|
1024
|
+
# ask interactively
|
|
1025
|
+
result = []
|
|
1026
|
+
puts(' (one per line, end with empty line)') if multiple
|
|
1027
|
+
loop do
|
|
1028
|
+
prompt = default_prompt
|
|
1029
|
+
prompt = "#{accept_list.join(' ')}\n#{default_prompt}" if accept_list
|
|
1030
|
+
entry = prompt_user_input(prompt, sensitive: option_attrs&.sensitive)
|
|
1031
|
+
break if entry.empty? && multiple
|
|
1032
|
+
entry = ExtendedValue.instance.evaluate(entry, context: 'interactive input')
|
|
1033
|
+
entry = self.class.get_from_list(entry, descr, accept_list) if accept_list
|
|
1034
|
+
return entry unless multiple
|
|
1035
|
+
result.push(entry)
|
|
1036
|
+
end
|
|
1037
|
+
result
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# Read remaining args and build an `Array` or `Hash`
|
|
1041
|
+
# @param value [String] Argument to `@:` extended value
|
|
1042
|
+
# @return [Hash, Array] Object representing dot-path values
|
|
1043
|
+
def args_as_extended(end_marker)
|
|
1044
|
+
# This extended value does not take args (`@:`)
|
|
1045
|
+
# ExtendedValue.assert_no_value(end_marker, :p)
|
|
1046
|
+
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
|
+
result = nil
|
|
1058
|
+
get_next_argument('args', multiple: end_marker).each do |argument|
|
|
1059
|
+
Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
|
|
1060
|
+
path, value = argument.split(Option::VALUE_SEP, 2)
|
|
1061
|
+
result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
|
|
1062
|
+
end
|
|
1063
|
+
# Restore skipped tokens so they remain available for command dispatching
|
|
1064
|
+
skipped_tokens.reverse_each { |t| @argv_tokens.unshift(t) }
|
|
1065
|
+
result
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
# Generate help text for all declared options, grouped by section.
|
|
1069
|
+
# @param banner [String, nil] Optional banner text to prepend
|
|
1070
|
+
# @return [String] Formatted help text
|
|
1071
|
+
def help_text(banner: nil)
|
|
1072
|
+
rows = []
|
|
1073
|
+
current_group = nil
|
|
1074
|
+
@declared_options.each do |sym, opt|
|
|
1075
|
+
if opt.group != current_group
|
|
1076
|
+
current_group = opt.group
|
|
1077
|
+
rows << [{value: "OPTIONS: #{current_group}", colspan: 2}]
|
|
1078
|
+
end
|
|
1079
|
+
short_char = @short_options.key(sym)
|
|
1080
|
+
short_part = short_char ? "-#{short_char}, " : ' '
|
|
1081
|
+
flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
|
|
1082
|
+
desc = opt.deprecation ? "#{opt.description} (deprecated: #{opt.deprecation})" : opt.description
|
|
1083
|
+
rows << [flag, desc]
|
|
1084
|
+
end
|
|
1085
|
+
table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
|
|
1086
|
+
banner.nil? ? table.to_s : "#{banner}\n#{table}"
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
# ======================================================
|
|
1090
|
+
private
|
|
1091
|
+
|
|
1092
|
+
# AsciiBorder with all visible characters removed - used by help_text
|
|
1093
|
+
HELP_BORDER = Terminal::Table::AsciiBorder.new.tap do |b|
|
|
1094
|
+
b.top = false
|
|
1095
|
+
b.bottom = false
|
|
1096
|
+
b.left = false
|
|
1097
|
+
b.right = false
|
|
1098
|
+
b.remove_verticals
|
|
1099
|
+
b.remove_horizontals
|
|
1100
|
+
end.freeze
|
|
1101
|
+
|
|
1102
|
+
# @param opt [OptionValue] option descriptor
|
|
1103
|
+
# @return [String, nil] placeholder shown in flag column: 'ENUM', 'HASH', 'INT', 'LIST', 'VALUE', or nil for flag switches
|
|
1104
|
+
def option_display_value(opt)
|
|
1105
|
+
case opt.types
|
|
1106
|
+
when Type::NONE then nil
|
|
1107
|
+
when Type::BOOLEAN then 'yes|no'
|
|
1108
|
+
when Type::INTEGER then 'INT'
|
|
1109
|
+
when Type::ENUM
|
|
1110
|
+
opt.values&.any? && opt.values.length <= 4 ? opt.values.join('|') : 'ENUM'
|
|
1111
|
+
else
|
|
1112
|
+
if opt.types&.include?(Hash) || !opt.schema.nil?
|
|
1113
|
+
'HASH'
|
|
1114
|
+
elsif opt.types&.include?(Array)
|
|
1115
|
+
'LIST'
|
|
1116
|
+
else
|
|
1117
|
+
'VALUE'
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# Dispatch a parsed CLI option to its handler.
|
|
1123
|
+
# @param sym [Symbol] option symbol
|
|
1124
|
+
# @param raw_value [String, nil] raw string value from command line, or nil for flag switches
|
|
1125
|
+
def dispatch_option(sym, raw_value)
|
|
1126
|
+
opt = @declared_options[sym]
|
|
1127
|
+
if opt.types.eql?(Type::NONE)
|
|
1128
|
+
opt.block.call
|
|
1129
|
+
else
|
|
1130
|
+
set_option(sym, raw_value, where: SOURCE_USER)
|
|
1131
|
+
# Track options explicitly cleared from CLI (e.g. --opt=@none:) so that
|
|
1132
|
+
# subsequent preset injection does not silently restore the value.
|
|
1133
|
+
# @none: always evaluates to nil; detect it by checking the raw token directly
|
|
1134
|
+
# to avoid re-evaluating side-effectful extended values (e.g. @: consumes positional args).
|
|
1135
|
+
cleared = get_option(sym).nil? || raw_value.eql?('@none:')
|
|
1136
|
+
@explicitly_cleared[sym] = true if cleared
|
|
1137
|
+
end
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
# Generate command line option string from option symbol
|
|
1141
|
+
# @param symbol [Symbol] option name
|
|
1142
|
+
# @param opt_val [String, nil] optional value placeholder
|
|
1143
|
+
# @return [String] formatted option string (e.g. "--option=VALUE")
|
|
1144
|
+
def symbol_to_option(symbol, opt_val = nil)
|
|
1145
|
+
result = self.class.option_name_to_line(symbol)
|
|
1146
|
+
opt_val.nil? ? result : "#{result}#{Option::VALUE_SEP}#{opt_val}"
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# TODO: use formatter
|
|
1150
|
+
# Highlight current value in list
|
|
1151
|
+
# @param list [Array<Symbol>] List of possible values
|
|
1152
|
+
# @param current [Symbol] Current value
|
|
1153
|
+
# @return [String] comma separated sorted list of values, with the current value highlighted
|
|
1154
|
+
def highlight_current_in_list(list, current)
|
|
1155
|
+
list.sort.map do |i|
|
|
1156
|
+
if i.eql?(current)
|
|
1157
|
+
$stdout.isatty ? i.to_s.red.bold : "[#{i}]"
|
|
1158
|
+
else
|
|
1159
|
+
i
|
|
1160
|
+
end
|
|
1161
|
+
end.join(', ')
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
# Try to evaluate options set in batch
|
|
1165
|
+
# @param unprocessed_options [Array] list of options to apply (key_sym,value)
|
|
1166
|
+
# @param where [String] where the options come from
|
|
1167
|
+
# Apply all known options from the given pairs hash and return the remaining (unknown) pairs.
|
|
1168
|
+
# Pure: does not mutate the argument; the caller is responsible for storing the returned value.
|
|
1169
|
+
# @param option_pairs [Hash{Symbol => Object}] candidate key/value pairs
|
|
1170
|
+
# @param where [String] label used in log messages and error context
|
|
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
|
|
1181
|
+
end
|
|
1182
|
+
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
|
+
end
|
|
1205
|
+
|
|
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
|
+
# Percent selector: select by this field for this value
|
|
1210
|
+
REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/
|
|
1211
|
+
# Ask for schema of Extended value
|
|
1212
|
+
HELP = 'help'
|
|
1213
|
+
|
|
1214
|
+
private_constant :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
|
|
1215
|
+
end
|
|
1216
|
+
end
|
|
1217
|
+
end
|