Browse Source

opts, help: refactor, parse cmdline opts natively, filter global opts

- command-line options are now parsed natively, without use of the getopt module
- global options and --longhelp helpscreen are now contextual, depending on coin
  and executed command
- commands invoked with out-of-context global options (e.g. `mmgen-walletgen
  --coin=btc`) now fail with an ‘unrecognized option’ error

Testing:

    $ test/test-release.sh help
    $ test/cmdtest.py opts
The MMGen Project 1 month ago
parent
commit
d7e3b55e3b

+ 8 - 4
mmgen/cfg.py

@@ -51,7 +51,10 @@ class GlobalConstants(Lockable):
 	min_time_precision = 18
 
 	# must match CoinProtocol.coins
-	core_coins = ('btc','bch','ltc','eth','etc','zec','xmr')
+	core_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'zec', 'xmr')
+	rpc_coins = ('btc', 'bch', 'ltc', 'eth', 'etc', 'xmr')
+	btc_fork_rpc_coins = ('btc', 'bch', 'ltc')
+	eth_fork_coins = ('eth', 'etc')
 
 	_cc = namedtuple('cmd_cap', ['proto', 'rpc', 'coin', 'caps', 'platforms'])
 	cmd_caps_data = {
@@ -461,8 +464,8 @@ class Config(Lockable):
 				opts_data    = opts_data,
 				init_opts    = init_opts,
 				opt_filter   = opt_filter,
-				parse_only   = parse_only,
-				parsed_opts  = parsed_opts )
+				parsed_opts  = parsed_opts,
+				need_proto   = need_proto)
 			self._uopt_desc = 'command-line option'
 		else:
 			if cfg is None:
@@ -575,7 +578,8 @@ class Config(Lockable):
 
 	def _post_init(self):
 		if self.help or self.longhelp:
-			self._opts.init_bottom(self) # exits
+			from .help import print_help
+			print_help(self, self._opts) # exits
 		del self._opts
 
 	def _usage(self):

+ 1 - 1
mmgen/data/release_date

@@ -1 +1 @@
-September 2024
+October 2024

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-15.1.dev1
+15.1.dev2

+ 1 - 0
mmgen/exception.py

@@ -48,6 +48,7 @@ class FileNotFound(Exception):            mmcode = 1
 class InvalidPasswdFormat(Exception):     mmcode = 1
 class CfgFileParseError(Exception):       mmcode = 1
 class UserOptError(Exception):            mmcode = 1
+class CmdlineOptError(Exception):         mmcode = 1
 class NoLEDSupport(Exception):            mmcode = 1
 class MsgFileFailedSID(Exception):        mmcode = 1
 class TestSuiteException(Exception):      mmcode = 1

+ 108 - 259
mmgen/help/__init__.py

@@ -20,7 +20,7 @@
 help: help notes for MMGen suite commands
 """
 
-import sys
+import sys, re
 
 from ..cfg import gc
 
@@ -60,261 +60,110 @@ def usage(cfg):
 	print(make_usage_str(cfg, caller='user'))
 	sys.exit(0)
 
-def help_notes_func(proto,cfg,k):
-
-	def fee_spec_letters(use_quotes=False):
-		cu = proto.coin_amt.units
-		sep,conj = ((',',' or '),("','","' or '"))[use_quotes]
-		return sep.join(u[0] for u in cu[:-1]) + ('',conj)[len(cu)>1] + cu[-1][0]
-
-	def fee_spec_names():
-		cu = proto.coin_amt.units
-		return ', '.join(cu[:-1]) + ('',' and ')[len(cu)>1] + cu[-1] + ('',',\nrespectively')[len(cu)>1]
-
-	def coind_exec():
-		from ..daemon import CoinDaemon
-		return (
-			CoinDaemon(cfg,proto.coin).exec_fn if proto.coin in CoinDaemon.coins else 'bitcoind' )
-
-	class help_notes:
-
-		def dfl_twname():
-			from ..proto.btc.rpc import BitcoinRPCClient
-			return BitcoinRPCClient.dfl_twname
-
-		def MasterShareIdx():
-			from ..seedsplit import MasterShareIdx
-			return MasterShareIdx
-
-		def tool_help():
-			from ..tool.help import main_help
-			return main_help()
-
-		def dfl_subseeds():
-			from ..subseed import SubSeedList
-			return str(SubSeedList.dfl_len)
-
-		def dfl_seed_len():
-			from ..seed import Seed
-			return str(Seed.dfl_len)
-
-		def password_formats():
-			from ..passwdlist import PasswordList
-			pwi_fs = '{:8} {:1} {:26} {:<7}  {:<7}  {}'
-			return '\n  '.join(
-				[pwi_fs.format('Code','','Description','Min Len','Max Len','Default Len')] +
-				[pwi_fs.format(k,'-',v.desc,v.min_len,v.max_len,v.dfl_len) for k,v in PasswordList.pw_info.items()]
-			)
-
-		def dfl_mmtype():
-			from ..addr import MMGenAddrType
-			return "'{}' or '{}'".format(
-				proto.dfl_mmtype,
-				MMGenAddrType.mmtypes[proto.dfl_mmtype].name )
-
-		def address_types():
-			from ..addr import MMGenAddrType
-			return '\n  '.join([
-				"'{}','{:<12} - {}".format( k, v.name+"'", v.desc )
-					for k,v in MMGenAddrType.mmtypes.items()
-			])
-
-		def fmt_codes():
-			from ..wallet import format_fmt_codes
-			return '\n  '.join( format_fmt_codes().splitlines() )
-
-		def coin_id():
-			return proto.coin_id
-
-		def keygen_backends():
-			from ..keygen import get_backends
-			from ..addr import MMGenAddrType
-			backends = get_backends(
-				MMGenAddrType(proto,cfg.type or proto.dfl_mmtype).pubkey_type
-			)
-			return ' '.join( f'{n}:{k}{" [default]" if n==1 else ""}' for n,k in enumerate(backends,1) )
-
-		def coind_exec():
-			return coind_exec()
-
-		def coin_daemon_network_ids():
-			from ..daemon import CoinDaemon
-			from ..util import fmt_list
-			return fmt_list(CoinDaemon.get_network_ids(cfg),fmt='bare')
-
-		def rel_fee_desc():
-			from ..tx import BaseTX
-			return BaseTX(cfg=cfg,proto=proto).rel_fee_desc
-
-		def fee_spec_letters():
-			return fee_spec_letters()
-
-		def fee():
-			from ..tx import BaseTX
-			return """
-                               FEE SPECIFICATION
-
-Transaction fees, both on the command line and at the interactive prompt, may
-be specified as either absolute {c} amounts, using a plain decimal number, or
-as {r}, using an integer followed by '{l}', for {u}.
-""".format(
-	c = proto.coin,
-	r = BaseTX(cfg=cfg,proto=proto).rel_fee_desc,
-	l = fee_spec_letters(use_quotes=True),
-	u = fee_spec_names() )
-
-		def passwd():
-			return """
-PASSPHRASE NOTE:
-
-For passphrases all combinations of whitespace are equal, and leading and
-trailing space are ignored.  This permits reading passphrase or brainwallet
-data from a multi-line file with free spacing and indentation.
-""".strip()
-
-		def brainwallet():
-			return """
-BRAINWALLET NOTE:
-
-To thwart dictionary attacks, it’s recommended to use a strong hash preset
-with brainwallets.  For a brainwallet passphrase to generate the correct
-seed, the same seed length and hash preset parameters must always be used.
-""".strip()
-
-		def txcreate_examples():
-
-			mmtype = 'B' if 'B' in proto.mmtypes else proto.mmtypes[0]
-			from ..tool.coin import tool_cmd
-			t = tool_cmd(cfg, mmtype=mmtype)
-			addr = t.privhex2addr('bead' * 16)
-			sample_addr = addr.views[addr.view_pref]
-
-			return f"""
-EXAMPLES:
-
-  Send 0.123 {proto.coin} to an external {proto.name} address, returning the change to a
-  specific MMGen address in the tracking wallet:
-
-    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7
-
-  Same as above, but select the change address automatically:
-
-    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}
-
-  Same as above, but select the change address automatically by address type:
-
-    $ {gc.prog_name} {sample_addr},0.123 {mmtype}
-
-  Same as above, but reduce verbosity and specify fee of 20 satoshis
-  per byte:
-
-    $ {gc.prog_name} -q -f 20s {sample_addr},0.123 {mmtype}
-
-  Send entire balance of selected inputs minus fee to an external {proto.name}
-  address:
-
-    $ {gc.prog_name} {sample_addr}
-
-  Send entire balance of selected inputs minus fee to first unused wallet
-  address of specified type:
-
-    $ {gc.prog_name} {mmtype}
-"""
-
-		def txcreate():
-			return f"""
-The transaction’s outputs are listed on the command line, while its inputs
-are chosen from a list of the wallet’s unspent outputs via an interactive
-menu.  Alternatively, inputs may be specified using the --inputs option.
-
-All addresses on the command line can be either {proto.name} addresses or MMGen
-IDs in the form <seed ID>:<address type letter>:<index>.
-
-Outputs are specified in the form <address>,<amount>, with the change output
-specified by address only.  Alternatively, the change output may be an
-addrlist ID in the form <seed ID>:<address type letter>, in which case the
-first unused address in the tracking wallet matching the requested ID will
-be automatically selected as the change output.
-
-If the transaction fee is not specified on the command line (see FEE
-SPECIFICATION below), it will be calculated dynamically using network fee
-estimation for the default (or user-specified) number of confirmations.
-If network fee estimation fails, the user will be prompted for a fee.
-
-Network-estimated fees will be multiplied by the value of --fee-adjust, if
-specified.
-
-To send the value of all inputs (minus TX fee) to a single output, specify
-a single address with no amount on the command line.  Alternatively, an
-addrlist ID may be specified, and the address will be chosen automatically
-as described above for the change output.
-"""
-
-		def txsign():
-			from ..proto.btc.params import mainnet
-			return """
-Transactions may contain both {pnm} or non-{pnm} input addresses.
-
-To sign non-{pnm} inputs, a {wd}flat key list is used
-as the key source (--keys-from-file option).
-
-To sign {pnm} inputs, key data is generated from a seed as with the
-{pnl}-addrgen and {pnl}-keygen commands.  Alternatively, a key-address file
-may be used (--mmgen-keys-from-file option).
-
-Multiple wallets or other seed files can be listed on the command line in
-any order.  If the seeds required to sign the transaction’s inputs are not
-found in these files (or in the default wallet), the user will be prompted
-for seed data interactively.
-
-To prevent an attacker from crafting transactions with bogus {pnm}-to-{pnu}
-address mappings, all outputs to {pnm} addresses are verified with a seed
-source.  Therefore, seed files or a key-address file for all {pnm} outputs
-must also be supplied on the command line if the data can’t be found in the
-default wallet.
-""".format(
-	wd  = (f'{coind_exec()} wallet dump or ' if isinstance(proto,mainnet) else ''),
-	pnm = gc.proj_name,
-	pnu = proto.name,
-	pnl = gc.proj_name.lower() )
-
-		def subwallet():
-			from ..subseed import SubSeedIdxRange
-			return f"""
-SUBWALLETS:
-
-Subwallets (subseeds) are specified by a ‘Subseed Index’ consisting of:
-
-  a) an integer in the range 1-{SubSeedIdxRange.max_idx}, plus
-  b) an optional single letter, ‘L’ or ‘S’
-
-The letter designates the length of the subseed.  If omitted, ‘L’ is assumed.
-
-Long (‘L’) subseeds are the same length as their parent wallet’s seed
-(typically 256 bits), while short (‘S’) subseeds are always 128-bit.
-The long and short subseeds for a given index are derived independently,
-so both may be used.
-
-MMGen Wallet has no notion of ‘depth’, and to an outside observer subwallets
-are identical to ordinary wallets.  This is a feature rather than a bug, as
-it denies an attacker any way of knowing whether a given wallet has a parent.
-
-Since subwallets are just wallets, they may be used to generate other
-subwallets, leading to hierarchies of arbitrary depth.  However, this is
-inadvisable in practice for two reasons:  Firstly, it creates accounting
-complexity, requiring the user to independently keep track of a derivation
-tree.  More importantly, however, it leads to the danger of Seed ID
-collisions between subseeds at different levels of the hierarchy, as
-MMGen checks and avoids ID collisions only among sibling subseeds.
-
-An exception to this caveat would be a multi-user setup where sibling
-subwallets are distributed to different users as their default wallets.
-Since the subseeds derived from these subwallets are private to each user,
-Seed ID collisions among them doesn’t present a problem.
-
-A safe rule of thumb, therefore, is for *each user* to derive all of his/her
-subwallets from a single parent.  This leaves each user with a total of two
-million subwallets, which should be enough for most practical purposes.
-""".strip()
-
-	return getattr(help_notes,k)()
+class Help:
+
+	def make(self, cfg, opts, proto):
+
+		def gen_arg_tuple(func, text):
+
+			def help_notes(k):
+				import importlib
+				return getattr(importlib.import_module(
+					f'{opts.help_pkg}.help_notes').help_notes(proto, cfg), k)()
+
+			def help_mod(modname):
+				import importlib
+				return importlib.import_module(
+					f'{opts.help_pkg}.{modname}').help(proto, cfg)
+
+			d = {
+				'proto':      proto,
+				'help_notes': help_notes,
+				'help_mod':   help_mod,
+				'cfg':        cfg,
+			}
+			for arg in func.__code__.co_varnames:
+				yield d[arg] if arg in d else text
+
+		def gen_output():
+			yield '  {} {}'.format(gc.prog_name.upper() + ':', text['desc'].strip())
+			yield make_usage_str(cfg, caller='help')
+			yield help_type.upper().replace('_', ' ') + ':'
+
+			# process code for options
+			opts_text = nl.join(self.gen_text(opts))
+			if help_type in code:
+				yield code[help_type](*tuple(gen_arg_tuple(code[help_type], opts_text)))
+			else:
+				yield opts_text
+
+			# process code for notes
+			if help_type == 'options' and 'notes' in text:
+				if 'notes' in code:
+					yield from code['notes'](*tuple(gen_arg_tuple(code['notes'], text['notes']))).splitlines()
+				else:
+					yield from text['notes'].splitlines()
+
+		text = opts.opts_data['text']
+		code = opts.opts_data['code']
+		help_type = self.help_type
+		nl = '\n  '
+
+		return nl.join(gen_output()) + '\n'
+
+class CmdHelp(Help):
+
+	help_type = 'options'
+
+	def gen_text(self, opts):
+		opt_filter = opts.opt_filter
+		from ..opts import cmd_opts_pat
+		skipping = False
+		for line in opts.opts_data['text']['options'].strip().splitlines():
+			if m := cmd_opts_pat.match(line):
+				if opt_filter:
+					if m[1] in opt_filter:
+						skipping = False
+					else:
+						skipping = True
+						continue
+				yield '{} --{} {}'.format(
+					(f'-{m[1]},', '   ')[m[1] == '-'],
+					m[2],
+					m[4])
+			elif not skipping:
+				yield line
+
+class GlobalHelp(Help):
+
+	help_type = 'global_options'
+
+	def gen_text(self, opts):
+		from ..opts import global_opts_pat
+		for line in opts.global_opts_data['text'][1:-2].splitlines():
+			if m := global_opts_pat.match(line):
+				if m[1] in opts.global_opts_filter.coin and m[2] in opts.global_opts_filter.cmd:
+					yield '  --{} {}'.format(m[3], m[5])
+					skipping = False
+				else:
+					skipping = True
+			elif not skipping:
+				yield line[4:]
+
+def print_help(cfg, opts):
+
+	from ..protocol import init_proto_from_cfg
+	proto = init_proto_from_cfg(cfg, need_amt=True)
+
+	if not 'code' in opts.opts_data:
+		opts.opts_data['code'] = {}
+
+	if cfg.help:
+		cls = CmdHelp
+	else:
+		opts.opts_data['code']['global_options'] = opts.global_opts_data['code']
+		cls = GlobalHelp
+
+	from ..ui import do_pager
+	do_pager(cls().make(cfg, opts, proto))
+	sys.exit(0)

+ 268 - 0
mmgen/help/help_notes.py

@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+#
+# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
+# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
+# Licensed under the GNU General Public License, Version 3:
+#   https://www.gnu.org/licenses
+# Public project repositories:
+#   https://github.com/mmgen/mmgen-wallet
+#   https://gitlab.com/mmgen/mmgen-wallet
+
+"""
+help: help notes functions for MMGen suite commands
+"""
+
+from ..cfg import gc
+
+class help_notes:
+
+	def __init__(self, proto, cfg):
+		self.proto = proto
+		self.cfg = cfg
+
+	def fee_spec_letters(self, use_quotes=False):
+		cu = self.proto.coin_amt.units
+		sep, conj = ((',', ' or '), ("','", "' or '"))[use_quotes]
+		return sep.join(u[0] for u in cu[:-1]) + ('', conj)[len(cu)>1] + cu[-1][0]
+
+	def fee_spec_names(self):
+		cu = self.proto.coin_amt.units
+		return ', '.join(cu[:-1]) + ('', ' and ')[len(cu)>1] + cu[-1] + ('', ',\nrespectively')[len(cu)>1]
+
+	def coind_exec(self):
+		from ..daemon import CoinDaemon
+		return (
+			CoinDaemon(self.cfg, self.proto.coin).exec_fn if self.proto.coin in CoinDaemon.coins else 'bitcoind')
+
+	def dfl_twname(self):
+		from ..proto.btc.rpc import BitcoinRPCClient
+		return BitcoinRPCClient.dfl_twname
+
+	def MasterShareIdx(self):
+		from ..seedsplit import MasterShareIdx
+		return MasterShareIdx
+
+	def tool_help(self):
+		from ..tool.help import main_help
+		return main_help()
+
+	def dfl_subseeds(self):
+		from ..subseed import SubSeedList
+		return str(SubSeedList.dfl_len)
+
+	def dfl_seed_len(self):
+		from ..seed import Seed
+		return str(Seed.dfl_len)
+
+	def password_formats(self):
+		from ..passwdlist import PasswordList
+		pwi_fs = '{:8} {:1} {:26} {:<7}  {:<7}  {}'
+		return '\n  '.join(
+			[pwi_fs.format('Code', '','Description', 'Min Len', 'Max Len', 'Default Len')] +
+			[pwi_fs.format(k, '-', v.desc, v.min_len, v.max_len, v.dfl_len)
+				for k, v in PasswordList.pw_info.items()]
+		)
+
+	def dfl_mmtype(self):
+		from ..addr import MMGenAddrType
+		return "'{}' or '{}'".format(self.proto.dfl_mmtype, MMGenAddrType.mmtypes[self.proto.dfl_mmtype].name)
+
+	def address_types(self):
+		from ..addr import MMGenAddrType
+		return '\n  '.join([
+			"'{}','{:<12} - {}".format(k, v.name + "'", v.desc)
+				for k, v in MMGenAddrType.mmtypes.items()
+		])
+
+	def fmt_codes(self):
+		from ..wallet import format_fmt_codes
+		return '\n  '.join(format_fmt_codes().splitlines())
+
+	def coin_id(self):
+		return self.proto.coin_id
+
+	def keygen_backends(self):
+		from ..keygen import get_backends
+		from ..addr import MMGenAddrType
+		backends = get_backends(
+			MMGenAddrType(self.proto, self.cfg.type or self.proto.dfl_mmtype).pubkey_type
+		)
+		return ' '.join('{n}:{k}{t}'.format(n=n, k=k, t=('', ' [default]')[n == 1])
+			for n, k in enumerate(backends, 1))
+
+	def coin_daemon_network_ids(self):
+		from ..daemon import CoinDaemon
+		from ..util import fmt_list
+		return fmt_list(CoinDaemon.get_network_ids(self.cfg), fmt='bare')
+
+	def rel_fee_desc(self):
+		from ..tx import BaseTX
+		return BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc
+
+	def fee(self):
+		from ..tx import BaseTX
+		return """
+                               FEE SPECIFICATION
+
+Transaction fees, both on the command line and at the interactive prompt, may
+be specified as either absolute {c} amounts, using a plain decimal number, or
+as {r}, using an integer followed by '{l}', for {u}.
+""".format(
+	c = self.proto.coin,
+	r = BaseTX(cfg=self.cfg, proto=self.proto).rel_fee_desc,
+	l = self.fee_spec_letters(use_quotes=True),
+	u = self.fee_spec_names() )
+
+	def passwd(self):
+		return """
+PASSPHRASE NOTE:
+
+For passphrases all combinations of whitespace are equal, and leading and
+trailing space are ignored.  This permits reading passphrase or brainwallet
+data from a multi-line file with free spacing and indentation.
+""".strip()
+
+	def brainwallet(self):
+		return """
+BRAINWALLET NOTE:
+
+To thwart dictionary attacks, it’s recommended to use a strong hash preset
+with brainwallets.  For a brainwallet passphrase to generate the correct
+seed, the same seed length and hash preset parameters must always be used.
+""".strip()
+
+	def txcreate_examples(self):
+
+		mmtype = 'B' if 'B' in self.proto.mmtypes else self.proto.mmtypes[0]
+		from ..tool.coin import tool_cmd
+		t = tool_cmd(self.cfg, mmtype=mmtype)
+		addr = t.privhex2addr('bead' * 16)
+		sample_addr = addr.views[addr.view_pref]
+
+		return f"""
+EXAMPLES:
+
+  Send 0.123 {self.proto.coin} to an external {self.proto.name} address, returning the change to a
+  specific MMGen address in the tracking wallet:
+
+    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}:7
+
+  Same as above, but select the change address automatically:
+
+    $ {gc.prog_name} {sample_addr},0.123 01ABCDEF:{mmtype}
+
+  Same as above, but select the change address automatically by address type:
+
+    $ {gc.prog_name} {sample_addr},0.123 {mmtype}
+
+  Same as above, but reduce verbosity and specify fee of 20 satoshis
+  per byte:
+
+    $ {gc.prog_name} -q -f 20s {sample_addr},0.123 {mmtype}
+
+  Send entire balance of selected inputs minus fee to an external {self.proto.name}
+  address:
+
+    $ {gc.prog_name} {sample_addr}
+
+  Send entire balance of selected inputs minus fee to first unused wallet
+  address of specified type:
+
+    $ {gc.prog_name} {mmtype}
+"""
+
+	def txcreate(self):
+		return f"""
+The transaction’s outputs are listed on the command line, while its inputs
+are chosen from a list of the wallet’s unspent outputs via an interactive
+menu.  Alternatively, inputs may be specified using the --inputs option.
+
+All addresses on the command line can be either {self.proto.name} addresses or MMGen
+IDs in the form <seed ID>:<address type letter>:<index>.
+
+Outputs are specified in the form <address>,<amount>, with the change output
+specified by address only.  Alternatively, the change output may be an
+addrlist ID in the form <seed ID>:<address type letter>, in which case the
+first unused address in the tracking wallet matching the requested ID will
+be automatically selected as the change output.
+
+If the transaction fee is not specified on the command line (see FEE
+SPECIFICATION below), it will be calculated dynamically using network fee
+estimation for the default (or user-specified) number of confirmations.
+If network fee estimation fails, the user will be prompted for a fee.
+
+Network-estimated fees will be multiplied by the value of --fee-adjust, if
+specified.
+
+To send the value of all inputs (minus TX fee) to a single output, specify
+a single address with no amount on the command line.  Alternatively, an
+addrlist ID may be specified, and the address will be chosen automatically
+as described above for the change output.
+"""
+
+	def txsign(self):
+		from ..proto.btc.params import mainnet
+		return """
+Transactions may contain both {pnm} or non-{pnm} input addresses.
+
+To sign non-{pnm} inputs, a {wd}flat key list is used
+as the key source (--keys-from-file option).
+
+To sign {pnm} inputs, key data is generated from a seed as with the
+{pnl}-addrgen and {pnl}-keygen commands.  Alternatively, a key-address file
+may be used (--mmgen-keys-from-file option).
+
+Multiple wallets or other seed files can be listed on the command line in
+any order.  If the seeds required to sign the transaction’s inputs are not
+found in these files (or in the default wallet), the user will be prompted
+for seed data interactively.
+
+To prevent an attacker from crafting transactions with bogus {pnm}-to-{pnu}
+address mappings, all outputs to {pnm} addresses are verified with a seed
+source.  Therefore, seed files or a key-address file for all {pnm} outputs
+must also be supplied on the command line if the data can’t be found in the
+default wallet.
+""".format(
+	wd  = f'{self.coind_exec()} wallet dump or ' if isinstance(self.proto, mainnet) else '',
+	pnm = gc.proj_name,
+	pnu = self.proto.name,
+	pnl = gc.proj_name.lower())
+
+	def subwallet(self):
+		from ..subseed import SubSeedIdxRange
+		return f"""
+SUBWALLETS:
+
+Subwallets (subseeds) are specified by a ‘Subseed Index’ consisting of:
+
+  a) an integer in the range 1-{SubSeedIdxRange.max_idx}, plus
+  b) an optional single letter, ‘L’ or ‘S’
+
+The letter designates the length of the subseed.  If omitted, ‘L’ is assumed.
+
+Long (‘L’) subseeds are the same length as their parent wallet’s seed
+(typically 256 bits), while short (‘S’) subseeds are always 128-bit.
+The long and short subseeds for a given index are derived independently,
+so both may be used.
+
+MMGen Wallet has no notion of ‘depth’, and to an outside observer subwallets
+are identical to ordinary wallets.  This is a feature rather than a bug, as
+it denies an attacker any way of knowing whether a given wallet has a parent.
+
+Since subwallets are just wallets, they may be used to generate other
+subwallets, leading to hierarchies of arbitrary depth.  However, this is
+inadvisable in practice for two reasons:  Firstly, it creates accounting
+complexity, requiring the user to independently keep track of a derivation
+tree.  More importantly, however, it leads to the danger of Seed ID
+collisions between subseeds at different levels of the hierarchy, as
+MMGen checks and avoids ID collisions only among sibling subseeds.
+
+An exception to this caveat would be a multi-user setup where sibling
+subwallets are distributed to different users as their default wallets.
+Since the subseeds derived from these subwallets are private to each user,
+Seed ID collisions among them doesn’t present a problem.
+
+A safe rule of thumb, therefore, is for *each user* to derive all of his/her
+subwallets from a single parent.  This leaves each user with a total of two
+million subwallets, which should be enough for most practical purposes.
+""".strip()

+ 228 - 107
mmgen/opts.py

@@ -17,13 +17,128 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 """
-opts: MMGen-specific command-line options processing after generic processing by share.Opts
+opts: command-line options processing for the MMGen Project
 """
-import sys,os
 
-from .share import Opts
+import sys, os, re
+from collections import namedtuple
+
 from .cfg import gc
 
+def get_opt_by_substring(opt, opts):
+	matches = [o for o in opts if o.startswith(opt)]
+	if len(matches) == 1:
+		return matches[0]
+	if len(matches) > 1:
+		from .util import die
+		die('CmdlineOptError', f'--{opt}: ambiguous option (not unique substring)')
+
+def process_uopts(opts_data, opts):
+
+	from .util import die
+
+	def get_uopts():
+		nonlocal uargs
+		idx = 1
+		argv_len = len(sys.argv)
+		while idx < argv_len:
+			arg = sys.argv[idx]
+			if len(arg) > 4096:
+				raise RuntimeError(f'{len(arg)} bytes: command-line argument too long')
+			if arg.startswith('--'):
+				if len(arg) == 2:
+					uargs = sys.argv[idx+1:]
+					return
+				opt, parm = arg[2:].split('=') if '=' in arg else (arg[2:], None)
+				if len(opt) < 2:
+					die('CmdlineOptError', f'--{opt}: option name must be at least two characters long')
+				if opt in opts or (opt := get_opt_by_substring(opt, opts)):
+					if opts[opt].has_parm:
+						if parm:
+							yield (opts[opt].name, parm)
+						else:
+							idx += 1
+							if idx == argv_len or (parm := sys.argv[idx]).startswith('-'):
+								die('CmdlineOptError', f'missing parameter for option --{opt}')
+							yield (opts[opt].name, parm)
+					else:
+						if parm:
+							die('CmdlineOptError', f'option --{opt} requires no parameter')
+						yield (opts[opt].name, True)
+				else:
+					opt, parm = arg[2:].split('=') if '=' in arg else (arg[2:], None)
+					die('CmdlineOptError', f'--{opt}: unrecognized option')
+			elif arg[0] == '-' and len(arg) > 1:
+				for j, sopt in enumerate(arg[1:]):
+					if sopt in opts:
+						if opts[sopt].has_parm:
+							if j > 0:
+								die('CmdlineOptError', f'{arg}: short option with parameters cannot be combined')
+							if arg[2:]:
+								yield (opts[sopt].name, arg[2:])
+							else:
+								idx += 1
+								if idx == argv_len or (parm := sys.argv[idx]).startswith('-'):
+									die('CmdlineOptError', f'missing parameter for option -{sopt}')
+								yield (opts[sopt].name, parm)
+							break
+						else:
+							yield (opts[sopt].name, True)
+					else:
+						die('CmdlineOptError', f'-{sopt}: unrecognized option')
+			else:
+				uargs = sys.argv[idx:]
+				return
+			idx += 1
+
+	uargs = []
+	uopts = dict(get_uopts())
+
+	if 'sets' in opts_data:
+		for a_opt, a_val, b_opt, b_val in opts_data['sets']:
+			if a_opt in uopts:
+				u_val = uopts[a_opt]
+				if (u_val and a_val == bool) or u_val == a_val:
+					if b_opt in uopts and uopts[b_opt] != b_val:
+						die(1,
+							'Option conflict:'
+							+ '\n  --{}={}, with'.format(b_opt.replace('_', '-'), uopts[b_opt])
+							+ '\n  --{}={}\n'.format(a_opt.replace('_', '-'), uopts[a_opt]))
+					else:
+						uopts[b_opt] = b_val
+
+	return uopts, uargs
+
+cmd_opts_pat = re.compile(r'^-([a-zA-Z0-9-]), --([a-zA-Z0-9-]{2,64})(=| )(.+)')
+global_opts_pat = re.compile(r'^\t\t\t(.)(.) --([a-zA-Z0-9-]{2,64})(=| )(.+)')
+ao = namedtuple('opt', ['name', 'has_parm'])
+
+def parse_opts(opts_data, opt_filter, global_opts_data, global_opts_filter):
+
+	def parse_cmd_opts_text():
+		for line in opts_data['text']['options'].strip().splitlines():
+			m = cmd_opts_pat.match(line)
+			if m and (not opt_filter or m[1] in opt_filter):
+				ret = ao(m[2].replace('-', '_'), m[3] == '=')
+				yield (m[1], ret)
+				yield (m[2], ret)
+
+	def parse_global_opts_text():
+		for line in global_opts_data['text'].splitlines():
+			m = global_opts_pat.match(line)
+			if m and m[1] in global_opts_filter.coin and m[2] in global_opts_filter.cmd:
+				yield (m[3], ao(m[3].replace('-', '_'), m[4] == '='))
+
+	opts = tuple(parse_cmd_opts_text()) + tuple(parse_global_opts_text())
+
+	uopts, uargs = process_uopts(opts_data, dict(opts))
+
+	return namedtuple('parsed_cmd_opts', ['user_opts', 'cmd_args', 'opts'])(
+		uopts, # dict
+		uargs, # list, callers can pop
+		tuple(v.name for k,v in opts if len(k) > 1)
+	)
+
 def opt_preproc_debug(po):
 	d = (
 		('Cmdline',            ' '.join(sys.argv), False),
@@ -37,48 +152,6 @@ def opt_preproc_debug(po):
 	for label,data,pretty in d:
 		Msg('    {:<20}: {}'.format(label,'\n' + fmt_list(data,fmt='col',indent=' '*8) if pretty else data))
 
-long_opts_data = {
-	'text': """
---, --accept-defaults      Accept defaults at all prompts
---, --coin=c               Choose coin unit. Default: BTC. Current choice: {cu_dfl}
---, --token=t              Specify an ERC20 token by address or symbol
---, --cashaddr=0|1         Display BCH addresses in cashaddr format (default: 1)
---, --color=0|1            Disable or enable color output (default: 1)
---, --columns=N            Force N columns of output with certain commands
---, --scroll               Use the curses-like scrolling interface for
-                         tracking wallet views
---, --force-256-color      Force 256-color output when color is enabled
---, --pager                Pipe output of certain commands to pager (WIP)
---, --data-dir=path        Specify {pnm} data directory location
---, --daemon-data-dir=path Specify coin daemon data directory location
---, --daemon-id=ID         Specify the coin daemon ID
---, --ignore-daemon-version Ignore coin daemon version check
---, --http-timeout=t       Set HTTP timeout in seconds for JSON-RPC connections
---, --no-license           Suppress the GPL license prompt
---, --rpc-host=HOST        Communicate with coin daemon running on host HOST
---, --rpc-port=PORT        Communicate with coin daemon listening on port PORT
---, --rpc-user=USER        Authenticate to coin daemon using username USER
---, --rpc-password=PASS    Authenticate to coin daemon using password PASS
---, --rpc-backend=backend  Use backend 'backend' for JSON-RPC communications
---, --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp
---, --regtest=0|1          Disable or enable regtest mode
---, --testnet=0|1          Disable or enable testnet
---, --tw-name=NAME         Specify alternate name for the BTC/LTC/BCH tracking
-                         wallet (default: ‘{tw_name}’)
---, --skip-cfg-file        Skip reading the configuration file
---, --usage                Print usage information and exit
---, --version              Print version information and exit
---, --bob                  Specify user “Bob” in MMGen regtest mode
---, --alice                Specify user “Alice” in MMGen regtest mode
---, --carol                Specify user “Carol” in MMGen regtest mode
-	""",
-	'code': lambda proto,help_notes,s: s.format(
-			pnm    = gc.proj_name,
-			cu_dfl = proto.coin,
-			tw_name = help_notes('dfl_twname')
-		)
-}
-
 opts_data_dfl = {
 	'text': {
 		'desc': '',
@@ -90,7 +163,22 @@ opts_data_dfl = {
 	}
 }
 
-class UserOpts:
+def get_coin():
+	for n, arg in enumerate(sys.argv[1:]):
+		if len(arg) > 4096:
+			raise RuntimeError(f'{len(arg)} bytes: command-line argument too long')
+		if arg.startswith('--coin='):
+			return arg.removeprefix('--coin=').lower()
+		if arg == '--coin':
+			if len(sys.argv) < n + 3:
+				from .util import die
+				die('CmdlineOptError', f'{arg}: missing parameter')
+			return sys.argv[n + 2].lower()
+		if arg == '-' or not arg.startswith('-'): # stop at first non-option
+			return 'btc'
+	return 'btc'
+
+class Opts:
 
 	def __init__(
 			self,
@@ -98,16 +186,19 @@ class UserOpts:
 			opts_data,
 			init_opts,    # dict containing opts to pre-initialize
 			opt_filter,   # whitelist of opt letters; all others are skipped
-			parse_only,
-			parsed_opts):
+			parsed_opts,
+			need_proto):
 
-		self.opts_data = od = opts_data or opts_data_dfl
+		if len(sys.argv) > 257:
+			raise RuntimeError(f'{len(sys.argv) - 1}: too many command-line arguments')
+
+		opts_data = opts_data or opts_data_dfl
 		self.opt_filter = opt_filter
 
-		od['text']['long_options'] = long_opts_data['text']
+		self.global_opts_filter = self.get_global_opts_filter(need_proto)
+		self.opts_data = opts_data
 
-		# po: (user_opts,cmd_args,opts,filtered_opts)
-		po = parsed_opts or Opts.parse_opts(od,opt_filter=opt_filter)
+		po = parsed_opts or parse_opts(opts_data, opt_filter, self.global_opts_data, self.global_opts_filter)
 
 		cfg._args = po.cmd_args
 		cfg._uopts = uopts = po.user_opts
@@ -127,60 +218,90 @@ class UserOpts:
 		if os.getenv('MMGEN_DEBUG_OPTS'):
 			opt_preproc_debug(po)
 
-		for funcname in ('usage', 'version', 'show_hash_presets'):
+		for funcname in self.info_funcs:
 			if funcname in uopts:
 				import importlib
-				getattr(importlib.import_module('mmgen.help'), funcname)(cfg) # exits
-
-		if parse_only:
-			return
-
-	def init_bottom(self,cfg):
-		# print help screen only after globals initialized and locked:
-		if cfg.help or cfg.longhelp:
-			self.print_help(cfg) # exits
-
-	def usage(self):
-		from .util import Die
-		Die(1,Opts.make_usage_str(gc.prog_name,'user',self.usage_data))
-
-	def version(self):
-		from .util import Die,fmt
-		Die(0,fmt(f"""
-			{gc.prog_name.upper()} version {gc.version}
-			Part of {gc.proj_name} Wallet, an online/offline cryptocurrency wallet for the
-			command line. Copyright (C){gc.Cdates} {gc.author} {gc.email}
-		""",indent='  ').rstrip())
-
-	def print_help(self,cfg):
-
-		if not 'code' in self.opts_data:
-			self.opts_data['code'] = {}
-
-		from .protocol import init_proto_from_cfg
-		proto = init_proto_from_cfg(cfg,need_amt=True)
-
-		if getattr(cfg,'longhelp',None):
-			self.opts_data['code']['long_options'] = long_opts_data['code']
-			def remove_unneeded_long_opts():
-				d = self.opts_data['text']['long_options']
-				if proto.base_proto != 'Ethereum':
-					d = '\n'.join(''+i for i in d.split('\n') if not '--token' in i)
-				self.opts_data['text']['long_options'] = d
-			remove_unneeded_long_opts()
-
-		from .ui import do_pager
-		do_pager(Opts.make_help( cfg, proto, self.opts_data, self.opt_filter ))
-
-		sys.exit(0)
-
-	def show_hash_presets(self):
-		fs = '      {:<6} {:<3} {:<2} {}'
-		from .util import msg
-		from .crypto import Crypto
-		msg('  Available parameters for scrypt.hash():')
-		msg(fs.format('Preset','N','r','p'))
-		for i in sorted(Crypto.hash_presets.keys()):
-			msg(fs.format(i,*Crypto.hash_presets[i]))
-		msg('  N = memory usage (power of two)\n  p = iterations (rounds)')
-		sys.exit(0)
+				getattr(importlib.import_module(self.help_pkg), funcname)(cfg) # exits
+
+class UserOpts(Opts):
+
+	help_pkg = 'mmgen.help'
+	info_funcs = ('usage', 'version', 'show_hash_presets')
+
+	global_opts_data = {
+		#  coin code : cmd code : opt : opt param : text
+		'text': """
+			-- --accept-defaults      Accept defaults at all prompts
+			hp --cashaddr=0|1         Display addresses in cashaddr format (default: 1)
+			-p --coin=c               Choose coin unit. Default: BTC. Current choice: {cu_dfl}
+			er --token=t              Specify an ERC20 token by address or symbol
+			-- --color=0|1            Disable or enable color output (default: 1)
+			-- --columns=N            Force N columns of output with certain commands
+			Rr --scroll               Use the curses-like scrolling interface for
+			+                         tracking wallet views
+			-- --force-256-color      Force 256-color output when color is enabled
+			-- --pager                Pipe output of certain commands to pager (WIP)
+			-- --data-dir=path        Specify {pnm} data directory location
+			rr --daemon-data-dir=path Specify coin daemon data directory location
+			Rr --daemon-id=ID         Specify the coin daemon ID
+			rr --ignore-daemon-version Ignore coin daemon version check
+			rr --http-timeout=t       Set HTTP timeout in seconds for JSON-RPC connections
+			-- --no-license           Suppress the GPL license prompt
+			rr --rpc-host=HOST        Communicate with coin daemon running on host HOST
+			rr --rpc-port=PORT        Communicate with coin daemon listening on port PORT
+			rr --rpc-user=USER        Authenticate to coin daemon using username USER
+			rr --rpc-password=PASS    Authenticate to coin daemon using password PASS
+			Rr --rpc-backend=backend  Use backend 'backend' for JSON-RPC communications
+			Rr --aiohttp-rpc-queue-len=N Use N simultaneous RPC connections with aiohttp
+			-p --regtest=0|1          Disable or enable regtest mode
+			-- --testnet=0|1          Disable or enable testnet
+			br --tw-name=NAME         Specify alternate name for the BTC/LTC/BCH tracking
+			+                         wallet (default: ‘{tw_name}’)
+			-- --skip-cfg-file        Skip reading the configuration file
+			-- --version              Print version information and exit
+			-- --usage                Print usage information and exit
+			b- --bob                  Specify user ‘Bob’ in MMGen regtest mode
+			b- --alice                Specify user ‘Alice’ in MMGen regtest mode
+			b- --carol                Specify user ‘Carol’ in MMGen regtest mode
+		""",
+		'code': lambda proto, help_notes, s: s.format(
+			pnm     = gc.proj_name,
+			cu_dfl  = proto.coin,
+			tw_name = help_notes('dfl_twname'))
+	}
+
+	@staticmethod
+	def get_global_opts_filter(need_proto):
+		"""
+		Coin codes:
+		  'b' - Bitcoin or Bitcoin code fork supporting RPC
+		  'R' - Bitcoin or Ethereum code fork supporting RPC
+		  'e' - Ethereum or Ethereum code fork
+		  'r' - coin supporting RPC
+		  'h' - Bitcoin Cash
+		  '-' - other coin
+		Cmd codes:
+		  'p' - proto required
+		  'r' - RPC required
+		  '-' - no capabilities required
+		"""
+		ret = namedtuple('global_opts_filter', ['coin', 'cmd'])
+		if caps := gc.cmd_caps:
+			coin = caps.coin if caps.coin and len(caps.coin) > 1 else get_coin()
+			return ret(
+				coin = (
+					('-', 'r', 'R', 'b', 'h') if coin == 'bch' else
+					('-', 'r', 'R', 'b') if coin in gc.btc_fork_rpc_coins else
+					('-', 'r', 'R', 'e') if coin in gc.eth_fork_coins else
+					('-', 'r') if coin in gc.rpc_coins else
+					('-')),
+				cmd = (
+					['-']
+					+ (['r'] if caps.rpc else [])
+					+ (['p'] if caps.proto else [])
+				))
+		else:
+			return ret(
+				coin = ('-', 'r', 'R', 'b', 'h', 'e'),
+				cmd = ('-', 'r', 'p')
+			)

+ 0 - 182
mmgen/share/Opts.py

@@ -1,182 +0,0 @@
-#!/usr/bin/env python3
-#
-# Opts.py, an options parsing library for Python.
-# Copyright (C)2013-2024 The MMGen Project <mmgen@tuta.io>
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""
-share.Opts: Generic options parsing
-"""
-
-import sys,re
-from collections import namedtuple
-
-pat = re.compile(r'^-([a-zA-Z0-9-]), --([a-zA-Z0-9-]{2,64})(=| )(.+)')
-
-def make_usage_str(prog_name,caller,data):
-	lines = [data.strip()] if isinstance(data,str) else data
-	indent,col1_w = {
-		'help': (2,len(prog_name)+1),
-		'user': (0,len('USAGE:')),
-	}[caller]
-	def gen():
-		ulbl = 'USAGE:'
-		for line in lines:
-			yield f'{ulbl:{col1_w}} {prog_name} {line}'
-			ulbl = ''
-	return ('\n'+(' '*indent)).join(gen())
-
-def usage(opts_data):
-	print(make_usage_str(
-		prog_name = opts_data['prog_name'],
-		caller    = 'user',
-		data      = opts_data['text'].get('usage2') or opts_data['text']['usage'] ))
-	sys.exit(1)
-
-def print_help(*args):
-	print(make_help(*args))
-	sys.exit(0)
-
-def make_help(cfg,proto,opts_data,opt_filter):
-
-	def parse_lines(text):
-		filtered = False
-		for line in text.strip().splitlines():
-			m = pat.match(line)
-			if m:
-				filtered = bool(opt_filter and m[1] not in opt_filter)
-				if not filtered:
-					yield fs.format( ('-'+m[1]+',','')[m[1]=='-'], m[2], m[4] )
-			elif not filtered:
-				yield line
-
-	opts_type,fs = ('options','{:<3} --{} {}') if cfg.help else ('long_options','{}  --{} {}')
-	t = opts_data['text']
-	c = opts_data['code']
-	nl = '\n  '
-
-	pn = opts_data['prog_name']
-
-	from ..help import help_notes_func
-	def help_notes(k):
-		return help_notes_func(proto,cfg,k)
-
-	def help_mod(modname):
-		import importlib
-		return importlib.import_module('mmgen.help.'+modname).help(proto,cfg)
-
-	def gen_arg_tuple(func,text):
-		d = {
-			'proto':      proto,
-			'help_notes': help_notes,
-			'help_mod':   help_mod,
-			'cfg':        cfg,
-		}
-		for arg in func.__code__.co_varnames:
-			yield d[arg] if arg in d else text
-
-	def gen_text():
-		yield '  {} {}'.format(pn.upper()+':',t['desc'].strip())
-		yield make_usage_str(pn,'help',t.get('usage2') or t['usage'])
-		yield opts_type.upper().replace('_',' ') + ':'
-
-		# process code for options
-		opts_text = nl.join(parse_lines(t[opts_type]))
-		if opts_type in c:
-			arg_tuple = tuple(gen_arg_tuple(c[opts_type],opts_text))
-			yield c[opts_type](*arg_tuple)
-		else:
-			yield opts_text
-
-		# process code for notes
-		if opts_type == 'options' and 'notes' in t:
-			notes_text = t['notes']
-			if 'notes' in c:
-				arg_tuple = tuple(gen_arg_tuple(c['notes'],notes_text))
-				notes_text = c['notes'](*arg_tuple)
-			yield from notes_text.splitlines()
-
-	return nl.join(gen_text()) + '\n'
-
-def process_uopts(opts_data,short_opts,long_opts):
-
-	import os,getopt
-	opts_data['prog_name'] = os.path.basename(sys.argv[0])
-
-	try:
-		cl_uopts,uargs = getopt.getopt(sys.argv[1:],''.join(short_opts),long_opts)
-	except getopt.GetoptError as e:
-		print(e.args[0])
-		sys.exit(1)
-
-	def get_uopts():
-		for uopt,uparm in cl_uopts:
-			if uopt.startswith('--'):
-				lo = uopt[2:]
-				if lo in long_opts:
-					yield (lo.replace('-','_'), True)
-				else: # lo+'=' in long_opts
-					yield (lo.replace('-','_'), uparm)
-			else: # uopt.startswith('-')
-				so = uopt[1]
-				if so in short_opts:
-					yield (long_opts[short_opts.index(so)].replace('-','_'), True)
-				else: # so+':' in short_opts
-					yield (long_opts[short_opts.index(so+':')][:-1].replace('-','_'), uparm)
-
-	uopts = dict(get_uopts())
-
-	if 'sets' in opts_data:
-		for a_opt,a_val,b_opt,b_val in opts_data['sets']:
-			if a_opt in uopts:
-				u_val = uopts[a_opt]
-				if (u_val and a_val == bool) or u_val == a_val:
-					if b_opt in uopts and uopts[b_opt] != b_val:
-						sys.stderr.write(
-							'Option conflict:'
-							+ '\n  --{}={}, with'.format(b_opt.replace('_','-'),uopts[b_opt])
-							+ '\n  --{}={}\n'.format(a_opt.replace('_','-'),uopts[a_opt]) )
-						sys.exit(1)
-					else:
-						uopts[b_opt] = b_val
-
-	return uopts,uargs
-
-def parse_opts(opts_data,opt_filter=None):
-
-	short_opts,long_opts,filtered_opts = [],[],[]
-	def parse_lines(opts_type):
-		for line in opts_data['text'][opts_type].strip().splitlines():
-			m = pat.match(line)
-			if m:
-				if opt_filter and m[1] not in opt_filter:
-					filtered_opts.append(m[2])
-				else:
-					if opts_type == 'options':
-						short_opts.append(m[1] + ('',':')[m[3] == '='])
-					long_opts.append(m[2] + ('','=')[m[3] == '='])
-
-	parse_lines('options')
-	if 'long_options' in opts_data['text']:
-		parse_lines('long_options')
-
-	uopts,uargs = process_uopts(opts_data,short_opts,long_opts)
-
-	return namedtuple('parsed_cmd_opts',['user_opts','cmd_args','opts','filtered_opts'])(
-		uopts, # dict
-		uargs, # list, callers can pop
-		tuple(o.replace('-','_').rstrip('=') for o in long_opts),
-		tuple(o.replace('-','_') for o in filtered_opts),
-	)

+ 0 - 0
mmgen/share/__init__.py


+ 2 - 0
pyproject.toml

@@ -82,4 +82,6 @@ ignored-classes = [ # ignored for no-member, otherwise checked
 	"GenTool",
 	"VirtBlockDeviceBase",
 	"SwapMgrBase",
+	"Opts",
+	"Help",
 ]

+ 0 - 1
setup.cfg

@@ -86,7 +86,6 @@ packages =
 	mmgen.proto.secp256k1
 	mmgen.proto.xmr
 	mmgen.proto.zec
-	mmgen.share
 	mmgen.tool
 	mmgen.tx
 	mmgen.tw

+ 1 - 1
test/cmdtest_py_d/ct_help.py

@@ -117,7 +117,7 @@ class CmdTestHelp(CmdTestBase):
 		return t
 
 	def longhelpscreens(self):
-		return self.helpscreens(arg='--longhelp',expect='USAGE:.*LONG OPTIONS:')
+		return self.helpscreens(arg='--longhelp',expect='USAGE:.*GLOBAL OPTIONS:')
 
 	def show_hash_presets(self):
 		return self.helpscreens(

+ 153 - 9
test/cmdtest_py_d/ct_opts.py

@@ -16,17 +16,44 @@ from ..include.common import cfg
 from .ct_base import CmdTestBase
 
 class CmdTestOpts(CmdTestBase):
-	'options processing'
+	'command-line options parsing and processing'
 	networks = ('btc',)
 	tmpdir_nums = [41]
 	cmd_group = (
-		('opt_helpscreen',       (41,"helpscreen output", [])),
-		('opt_noargs',           (41,"invocation with no user options or arguments", [])),
-		('opt_good',             (41,"good opts", [])),
-		('opt_bad_infile',       (41,"bad infile parameter", [])),
-		('opt_bad_outdir',       (41,"bad outdir parameter", [])),
-		('opt_bad_incompatible', (41,"incompatible opts", [])),
-		('opt_bad_autoset',      (41,"invalid autoset value", [])),
+		('opt_helpscreen',       (41, 'helpscreen output', [])),
+		('opt_noargs',           (41, 'invocation with no user options or arguments', [])),
+		('opt_good1',            (41, 'good opts (long opts only)', [])),
+		('opt_good2',            (41, 'good opts (mixed short and long opts)', [])),
+		('opt_good3',            (41, 'good opts (max arg count)', [])),
+		('opt_good4',            (41, 'good opts (maxlen arg)', [])),
+		('opt_good5',            (41, 'good opts (long opt substring)', [])),
+		('opt_good6',            (41, 'good global opt (--coin=xmr)', [])),
+		('opt_good7',            (41, 'good global opt (--coin xmr)', [])),
+		('opt_good8',            (41, 'good global opt (--pager)', [])),
+		('opt_good9',            (41, 'good cmdline arg ‘-’', [])),
+		('opt_good10',           (41, 'good cmdline arg ‘-’ with arg', [])),
+		('opt_good11',           (41, 'good cmdline arg ‘-’ with option', [])),
+		('opt_bad_param',        (41, 'bad global opt (--pager=1)', [])),
+		('opt_bad_infile',       (41, 'bad infile parameter', [])),
+		('opt_bad_outdir',       (41, 'bad outdir parameter', [])),
+		('opt_bad_incompatible', (41, 'incompatible opts', [])),
+		('opt_bad_autoset',      (41, 'invalid autoset value', [])),
+		('opt_invalid_1',     (41, 'invalid cmdline opt ‘--x’', [])),
+		('opt_invalid_2',     (41, 'invalid cmdline opt ‘---’', [])),
+		('opt_invalid_3',     (41, 'invalid cmdline opt (combined short opt with param)', [])),
+		('opt_invalid_4',     (41, 'invalid cmdline opt (combined short opt with param)', [])),
+		('opt_invalid_5',     (41, 'invalid cmdline opt (missing parameter)', [])),
+		('opt_invalid_6',     (41, 'invalid cmdline opt (missing parameter)', [])),
+		('opt_invalid_7',     (41, 'invalid cmdline opt (parameter not required)', [])),
+		('opt_invalid_8',     (41, 'invalid cmdline opt (non-existent option)', [])),
+		('opt_invalid_9',     (41, 'invalid cmdline opt (non-existent option)', [])),
+		('opt_invalid_10',    (41, 'invalid cmdline opt (missing parameter)', [])),
+		('opt_invalid_11',    (41, 'invalid cmdline opt (missing parameter)', [])),
+		('opt_invalid_12',    (41, 'invalid cmdline opt (non-existent option)', [])),
+		('opt_invalid_13',    (41, 'invalid cmdline opt (ambiguous long opt substring)', [])),
+		('opt_invalid_14',    (41, 'invalid cmdline opt (long opt substring too short)', [])),
+		('opt_invalid_15',    (41, 'invalid cmdline (too many args)', [])),
+		('opt_invalid_16',    (41, 'invalid cmdline (overlong arg)', [])),
 	)
 
 	def spawn_prog(self, args, exit_val=None):
@@ -65,10 +92,12 @@ class CmdTestOpts(CmdTestBase):
 					('cfg.outdir',              ''),             # check_outdir()
 					('cfg.cached_balances',     'False'),
 					('cfg.minconf',             '1'),
+					('cfg.coin',                'BTC'),
+					('cfg.pager',               'False'),
 					('cfg.fee_estimate_mode',   'conservative'), # _autoset_opts
 				))
 
-	def opt_good(self):
+	def opt_good1(self):
 		pf_base = 'testfile'
 		pf = os.path.join(self.tmpdir,pf_base)
 		self.write_to_tmpfile(pf_base,'')
@@ -90,6 +119,68 @@ class CmdTestOpts(CmdTestBase):
 					('cfg.fee_estimate_mode',         'economical'),
 				))
 
+	def opt_good2(self):
+		return self.check_vals(
+				[
+					'--print-checksum',
+					'-qX',
+					f'--outdir={self.tmpdir}',
+					'-p5',
+					'-m', '0',
+					'--seed-len=256',
+					'-L--my-label',
+					'--seed-len', '128',
+					'--min-temp=-30',
+					'-T-10',
+					'--',
+					'x', 'y', '12345'
+				], (
+					('cfg.print_checksum',  'True'),
+					('cfg.quiet',           'True'),
+					('cfg.outdir',          self.tmpdir),
+					('cfg.cached_balances', 'True'),
+					('cfg.minconf',         '0'),
+					('cfg.keep_label',      'None'),
+					('cfg.seed_len',        '128'),
+					('cfg.hash_preset',     '5'),
+					('cfg.label',           '--my-label'),
+					('cfg.min_temp',     '-30'),
+					('cfg.max_temp',     '-10'),
+					('arg1',           'x'),
+					('arg2',           'y'),
+					('arg3',           '12345'),
+				))
+
+	def opt_good3(self):
+		return self.check_vals(['m'] * 256, (('arg256', 'm'),))
+
+	def opt_good4(self):
+		return self.check_vals(['e' * 4096], (('arg1', 'e' * 4096),))
+
+	def opt_good5(self):
+		return self.check_vals(['--minc=7'], (('cfg.minconf', '7'),))
+
+	def opt_good6(self):
+		return self.check_vals(['--coin=xmr'], (('cfg.coin', 'XMR'),))
+
+	def opt_good7(self):
+		return self.check_vals(['--coin', 'xmr'], (('cfg.coin', 'XMR'),))
+
+	def opt_good8(self):
+		return self.check_vals(['--pager'], (('cfg.pager', 'True'),))
+
+	def opt_good9(self):
+		return self.check_vals(['-'], (('arg1', '-'),))
+
+	def opt_good10(self):
+		return self.check_vals(['-', '-x'], (('arg1', '-'), ('arg2', '-x')))
+
+	def opt_good11(self):
+		return self.check_vals(['-q', '-', '-x'], (('arg1', '-'), ('arg2', '-x')))
+
+	def opt_bad_param(self):
+		return self.do_run(['--pager=1'], 'no parameter', 1)
+
 	def opt_bad_infile(self):
 		pf = os.path.join(self.tmpdir,'fubar')
 		return self.do_run(['--passwd-file='+pf],'not found',1)
@@ -103,3 +194,56 @@ class CmdTestOpts(CmdTestBase):
 
 	def opt_bad_autoset(self):
 		return self.do_run(['--fee-estimate-mode=Fubar'],'not unique substring',1)
+
+	def opt_invalid(self, args, expect, exit_val):
+		t = self.spawn_prog(args, exit_val=exit_val)
+		t.expect(expect)
+		return t
+
+	def opt_invalid_1(self):
+		return self.opt_invalid(['--x'], 'must be at least', 1)
+
+	def opt_invalid_2(self):
+		return self.opt_invalid(['---'], 'must be at least', 1)
+
+	def opt_invalid_3(self):
+		return self.opt_invalid(['-kl3'], 'short option with parameters', 1)
+
+	def opt_invalid_4(self):
+		return self.opt_invalid(['-kl 3'], 'short option with parameters', 1)
+
+	def opt_invalid_5(self):
+		return self.opt_invalid(['-l'], 'missing parameter', 1)
+
+	def opt_invalid_6(self):
+		return self.opt_invalid(['-l', '-k'], 'missing parameter', 1)
+
+	def opt_invalid_7(self):
+		return self.opt_invalid(['--quiet=1'], 'requires no parameter', 1)
+
+	def opt_invalid_8(self):
+		return self.opt_invalid(['-x'], 'unrecognized option', 1)
+
+	def opt_invalid_9(self):
+		return self.opt_invalid(['--frobnicate'], 'unrecognized option', 1)
+
+	def opt_invalid_10(self):
+		return self.opt_invalid(['--label', '-q'], 'missing parameter', 1)
+
+	def opt_invalid_11(self):
+		return self.opt_invalid(['-T', '-10'], 'missing parameter', 1)
+
+	def opt_invalid_12(self):
+		return self.opt_invalid(['-q', '-10'], 'unrecognized option', 1)
+
+	def opt_invalid_13(self):
+		return self.opt_invalid(['--mi=3'], 'ambiguous option', 1)
+
+	def opt_invalid_14(self):
+		return self.opt_invalid(['--m=3'], 'must be at least', 1)
+
+	def opt_invalid_15(self):
+		return self.opt_invalid(['m'] * 257, 'too many', 1)
+
+	def opt_invalid_16(self):
+		return self.opt_invalid(['e' * 4097], 'too long', 1)

+ 14 - 0
test/misc/opts_main.py

@@ -25,6 +25,8 @@ opts_data = {
 -p, --hash-preset= p  Use the scrypt hash parameters defined by preset 'p'
 -P, --passwd-file= f  Get wallet passphrase from file 'f'
 -q, --quiet           Be quieter
+-t, --min-temp=    t  Minimum temperature (in degrees Celsius)
+-T, --max-temp=    t  Maximum temperature (in degrees Celsius)
 -X, --cached-balances Use cached balances (Ethereum only)
 -v, --verbose         Be more verbose
                       sample help_note: {kgs}
@@ -57,6 +59,14 @@ for k in (
 	'cached_balances',   # opt_sets_global
 	'minconf',           # global_sets_opt
 	'hidden_incog_input_params',
+	'keep_label',
+	'seed_len',
+	'hash_preset',
+	'label',
+	'min_temp',
+	'max_temp',
+	'coin',
+	'pager',
 	):
 	msg('{:30} {}'.format( f'cfg.{k}:', getattr(cfg,k) ))
 
@@ -72,3 +82,7 @@ for k in (
 	'fee_estimate_mode', # _autoset_opts
 	):
 	msg('{:30} {}'.format( f'cfg.{k}:', getattr(cfg,k) ))
+
+msg('')
+for n, k in enumerate(cfg._args, 1):
+	msg(f'arg{n}: {k}')