Use click built-in tooling for more eager and through CLI input validation; Document all CLI options.

pull/465/head
Kieran Prasch 2018-10-02 21:06:11 -07:00
parent eea6e5ea01
commit 99532d581a
3 changed files with 106 additions and 89 deletions

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import collections import collections
import hashlib
import json import json
import logging import logging
import os import os
@ -39,6 +40,14 @@ from nucypher.utilities.sandbox.ursula import UrsulaProcessProtocol
__version__ = '0.1.0-alpha.0' __version__ = '0.1.0-alpha.0'
def echo_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
click.echo(__version__)
ctx.exit()
BANNER = """ BANNER = """
_ _
| | | |
@ -140,17 +149,12 @@ class NucypherClickConfig:
def create_account(self) -> str: def create_account(self) -> str:
"""Creates a new local or hosted ethereum wallet""" """Creates a new local or hosted ethereum wallet"""
choice = click.prompt("Create a new Hosted or Local account?", default='hosted', type=str).strip().lower() choice = click.prompt("Create a new Hosted or Local account?", default='hosted', type=click.STRING).strip().lower()
if choice not in ('hosted', 'local'): if choice not in ('hosted', 'local'):
click.echo("Invalid Input") click.echo("Invalid Input")
raise click.Abort() raise click.Abort()
passphrase = click.prompt("Enter a passphrase to encrypt your wallet's private key") passphrase = click.prompt("Enter a passphrase to encrypt your wallet's private key", hide_input=True, confirmation_prompt=True)
passphrase_confirmation = click.prompt("Confirm passphrase to encrypt your wallet's private key")
if passphrase != passphrase_confirmation:
click.echo("Passphrases did not match")
raise click.Abort()
if choice == 'local': if choice == 'local':
keyring = generate_local_wallet(passphrase=passphrase, keyring_root=self.node_configuration.keyring_dir) keyring = generate_local_wallet(passphrase=passphrase, keyring_root=self.node_configuration.keyring_dir)
new_address = keyring.transacting_public_key new_address = keyring.transacting_public_key
@ -163,12 +167,12 @@ class NucypherClickConfig:
def create_node_tls_certificate(self, common_name: str, full_filepath: str) -> None: def create_node_tls_certificate(self, common_name: str, full_filepath: str) -> None:
days = click.prompt("How many days do you want the certificate to remain valid? (365 is default)", days = click.prompt("How many days do you want the certificate to remain valid? (365 is default)",
default=365, default=365,
type=int) # TODO: Perhaps make this equal to the stake length? type=click.INT) # TODO: Perhaps make this equal to the stake length?
host = click.prompt("Enter the node's hostname", default='localhost') # TODO: remove localhost as default host = click.prompt("Enter the node's hostname", default='127.0.0.1') # TODO: remove localhost/loopback as default?
# TODO: save TLS private key # TODO: save TLS private key
certificate, private_key = generate_self_signed_certificate(host=common_name, certificate, private_key = generate_self_signed_certificate(host=host,
days_valid=days, days_valid=days,
curve=ec.SECP384R1) # TODO: use Config class? curve=ec.SECP384R1) # TODO: use Config class?
@ -176,26 +180,39 @@ class NucypherClickConfig:
_save_tls_certificate(certificate=certificate, full_filepath=certificate_filepath) _save_tls_certificate(certificate=certificate, full_filepath=certificate_filepath)
# Register the above class as a decorator
uses_config = click.make_pass_decorator(NucypherClickConfig, ensure=True) uses_config = click.make_pass_decorator(NucypherClickConfig, ensure=True)
# Custom input type
class ChecksumAddress(click.ParamType):
name = 'checksum_address'
def convert(self, value, param, ctx):
if is_checksum_address(value):
return value
self.fail('{} is not a valid integer'.format(value, param, ctx))
CHECKSUM_ADDRESS = ChecksumAddress()
@click.group() @click.group()
@click.option('--version', is_flag=True) @click.option('--version', help="Echo the CLI version", is_flag=True, callback=echo_version, expose_value=False, is_eager=True)
@click.option('--verbose', is_flag=True) @click.option('-v', '--verbose', help="Specify verbosity level", count=True)
@click.option('--dev', is_flag=True) @click.option('--dev', help="Run in development mode", is_flag=True)
@click.option('--federated-only', is_flag=True) @click.option('--federated-only', help="Connect only to federated nodes", is_flag=True)
@click.option('--config-root', type=click.Path()) @click.option('--config-root', help="Custom configuration directory", type=click.Path())
@click.option('--config-file', type=click.Path()) @click.option('--config-file', help="Path to configuration file", type=click.Path(exists=True, dir_okay=False, file_okay=True, readable=True))
@click.option('--metadata-dir', type=click.Path()) @click.option('--metadata-dir', help="Custom known metadata directory", type=click.Path(exists=True, dir_okay=True, file_okay=False, writable=True))
@click.option('--provider-uri', type=str) @click.option('--provider-uri', help="Blockchain provider's URI", type=click.STRING)
@click.option('--compile', is_flag=True) @click.option('--compile/--no-compile', help="Compile solidity from source files", is_flag=True)
@click.option('--registry-filepath', type=click.Path()) @click.option('--registry-filepath', help="Custom contract registry filepath", type=click.Path(exists=True, dir_okay=False, file_okay=True, readable=True))
@click.option('--deployer', is_flag=True) @click.option('--deployer', help="Connect using a deployer's blockchain interface", is_flag=True)
@click.option('--poa', is_flag=True) @click.option('--poa', help="Inject POA middleware", is_flag=True)
@uses_config @uses_config
def cli(config, def cli(config,
verbose, verbose,
version,
dev, dev,
federated_only, federated_only,
config_root, config_root,
@ -224,9 +241,6 @@ def cli(config,
config.deployer = deployer config.deployer = deployer
config.poa = poa config.poa = poa
if version:
click.echo("Version {}".format(__version__))
if config.verbose: if config.verbose:
click.echo("Running in verbose mode...") click.echo("Running in verbose mode...")
@ -238,18 +252,18 @@ def cli(config,
@cli.command() @cli.command()
@click.option('--filesystem', is_flag=True, default=False) @click.option('--filesystem', is_flag=True, default=False)
@click.option('--no-registry', is_flag=True) @click.option('--no-registry', help="Skip importing the default contract registry", is_flag=True)
@click.option('--force', is_flag=True) @click.option('--force', help="Ask confirm once; Do not generate wallet or certificate", is_flag=True)
@click.option('--checksum-address', type=str) @click.option('--checksum-address', type=CHECKSUM_ADDRESS)
@click.argument('action') @click.argument('action')
@uses_config @uses_config
def configure(config, def configure(config,
action, action,
filesystem, filesystem,
no_registry, no_registry,
checksum_address, checksum_address, # TODO: Clean by address
force): force):
"""Manage local nucypher files and directories"""
# #
# Initialize # Initialize
# #
@ -300,7 +314,7 @@ def configure(config,
# Action switch # Action switch
# #
config.get_node_configuration() config.get_node_configuration()
if action == "init": if action == "install":
__initialize(config.node_configuration) __initialize(config.node_configuration)
elif action == "destroy": elif action == "destroy":
__destroy(config.node_configuration) __destroy(config.node_configuration)
@ -314,7 +328,7 @@ def configure(config,
@cli.command() @cli.command()
@click.option('--checksum-address', help="The account to lock/unlock instead of the default", type=str) @click.option('--checksum-address', help="The account to lock/unlock instead of the default", type=CHECKSUM_ADDRESS)
@click.argument('action', default='list', required=False) @click.argument('action', default='list', required=False)
@uses_config @uses_config
def accounts(config, def accounts(config,
@ -339,7 +353,7 @@ def accounts(config,
if not is_checksum_address(destination): if not is_checksum_address(destination):
click.echo("{} is not a valid checksum checksum_address".format(destination)) click.echo("{} is not a valid checksum checksum_address".format(destination))
raise click.Abort() raise click.Abort()
amount = click.prompt("Enter amount of {} to transfer".format(denomination), type=int) amount = click.prompt("Enter amount of {} to transfer".format(denomination), type=click.INT)
return destination, amount return destination, amount
# #
@ -359,7 +373,7 @@ def accounts(config,
elif action == 'export': elif action == 'export':
keyring = NucypherKeyring(common_name=checksum_address) keyring = NucypherKeyring(common_name=checksum_address)
click.confirm("Export local private key for {} to node's keyring: {}?".format(checksum_address, config.provider_uri), abort=True) click.confirm("Export local private key for {} to node's keyring: {}?".format(checksum_address, config.provider_uri), abort=True)
passphrase = click.prompt("Enter passphrase to decrypt account", type=str) passphrase = click.prompt("Enter passphrase to decrypt account", type=click.STRING, hide_input=True, confirmation_prompt=True)
keyring._export(blockchain=config.blockchain, passphrase=passphrase) keyring._export(blockchain=config.blockchain, passphrase=passphrase)
elif action == 'list': elif action == 'list':
@ -396,10 +410,10 @@ def accounts(config,
@cli.command() @cli.command()
@click.option('--checksum-address', type=str) @click.option('--checksum-address', type=CHECKSUM_ADDRESS)
@click.option('--value', help="Stake value in the smallest denomination", type=int) @click.option('--value', help="Token value of stake", type=click.IntRange(min=MIN_ALLOWED_LOCKED, max=MIN_ALLOWED_LOCKED, clamp=False))
@click.option('--duration', help="Stake duration in periods", type=int) @click.option('--duration', help="Period duration of stake", type=click.IntRange(min=MIN_LOCKED_PERIODS, max=MAX_MINTING_PERIODS, clamp=False))
@click.option('--index', help="A specific stake index to resume", type=int) @click.option('--index', help="A specific stake index to resume", type=click.INT)
@click.argument('action', default='list', required=False) @click.argument('action', default='list', required=False)
@uses_config @uses_config
def stake(config, def stake(config,
@ -409,7 +423,8 @@ def stake(config,
value, value,
duration): duration):
""" """
Manage active and inactive node blockchain stakes. Manage token staking.
Arguments Arguments
========== ==========
@ -452,7 +467,7 @@ def stake(config,
click.echo(row) click.echo(row)
click.echo("Select ethereum address") click.echo("Select ethereum address")
account_selection = click.prompt("Enter 0-{}".format(len(config.accounts)), type=int) account_selection = click.prompt("Enter 0-{}".format(len(config.accounts)), type=click.INT)
address = config.accounts[account_selection] address = config.accounts[account_selection]
if action == 'list': if action == 'list':
@ -471,13 +486,13 @@ def stake(config,
# Value # Value
balance = config.token_agent.get_balance(address=address) balance = config.token_agent.get_balance(address=address)
click.echo("Current balance: {}".format(balance)) click.echo("Current balance: {}".format(balance))
value = click.prompt("Enter stake value", type=int) value = click.prompt("Enter stake value", type=click.INT)
# Duration # Duration
message = "Minimum duration: {} | Maximum Duration: {}".format(constants.MIN_LOCKED_PERIODS, message = "Minimum duration: {} | Maximum Duration: {}".format(constants.MIN_LOCKED_PERIODS,
constants.MAX_REWARD_PERIODS) constants.MAX_REWARD_PERIODS)
click.echo(message) click.echo(message)
duration = click.prompt("Enter stake duration in days", type=int) duration = click.prompt("Enter stake duration in days", type=click.INT)
start_period = config.miner_agent.get_current_period() start_period = config.miner_agent.get_current_period()
end_period = start_period + duration end_period = start_period + duration
@ -535,10 +550,10 @@ def stake(config,
if not index: if not index:
for selection_index, stake_info in enumerate(stakes): for selection_index, stake_info in enumerate(stakes):
click.echo("{} ....... {}".format(selection_index, stake_info)) click.echo("{} ....... {}".format(selection_index, stake_info))
index = click.prompt("Select a stake to divide", type=int) index = click.prompt("Select a stake to divide", type=click.INT)
target_value = click.prompt("Enter new target value", type=int) target_value = click.prompt("Enter new target value", type=click.INT)
extension = click.prompt("Enter number of periods to extend", type=int) extension = click.prompt("Enter number of periods to extend", type=click.INT)
click.echo(""" click.echo("""
Current Stake: {} Current Stake: {}
@ -569,9 +584,9 @@ def stake(config,
@cli.command() @cli.command()
@click.option('--geth', is_flag=True) @click.option('--geth', help="Simulate with geth", is_flag=True)
@click.option('--pyevm', is_flag=True) @click.option('--pyevm', help="Simulate with PyEVM", is_flag=True)
@click.option('--nodes', help="The number of nodes to simulate", type=int, default=10) @click.option('--nodes', help="The number of nodes to simulate", type=click.INT, default=10)
@click.argument('action') @click.argument('action')
@uses_config @uses_config
def simulate(config, def simulate(config,
@ -580,7 +595,7 @@ def simulate(config,
geth, geth,
pyevm): pyevm):
""" """
Locally simulate the nucypher blockchain network Locally simulate the nucypher network
action - Which action to perform; The choices are: action - Which action to perform; The choices are:
- start: Start a multi-process nucypher network simulation - start: Start a multi-process nucypher network simulation
@ -777,22 +792,27 @@ def simulate(config,
@cli.command() @cli.command()
@click.option('--contract-name', type=str) @click.option('--contract-name', help="Deploy a single contract by name", type=click.STRING)
@click.option('--force', is_flag=True) @click.option('--force', is_flag=True)
@click.option('--deployer_address', type=str) @click.option('--deployer-address', help="Deployer's checksum address", type=CHECKSUM_ADDRESS)
@click.option('--registry-outfile', help="Output path for new registry", type=click.Path(), default=NodeConfiguration.REGISTRY_SOURCE)
@click.argument('action') @click.argument('action')
@uses_config @uses_config
def deploy(config, def deploy(config,
action, action,
deployer_address, deployer_address,
contract_name, contract_name,
registry_outfile,
force): force):
"""Manage contract and registry deployment"""
if not config.deployer: if not config.deployer:
click.echo("The --deployer flag must be used to issue the deploy command.") click.echo("The --deployer flag must be used to issue the deploy command.")
raise click.Abort() raise click.Abort()
def __get_deployers(): def __get_deployers():
config.registry_filepath = registry_outfile
config.connect_to_blockchain() config.connect_to_blockchain()
config.blockchain.interface.deployer_address = deployer_address or config.accounts[0] config.blockchain.interface.deployer_address = deployer_address or config.accounts[0]
@ -852,25 +872,18 @@ def deploy(config,
__deployer_init_args.update({dependant: __deployment_agents[dependant]}) __deployer_init_args.update({dependant: __deployment_agents[dependant]})
if upgradeable: if upgradeable:
def __collect_secret(): def __collect_secret_hash():
# secret = click.prompt("Enter secret hash for {}".format(__contract_name)) secret = click.prompt("Enter secret hash for {}".format(__contract_name), hide_input=True, confirmation_prompt=True)
# secret_confirmation = click.prompt("Confirm secret hash for {}".format(__contract_name)) secret_hash = hashlib.sha256(secret)
secret = os.urandom(32) # TODO: How to we handle deployment secrets? if len(secret_hash) != 32:
secret_confirmation = secret[:]
if len(bytes(secret)) != 32:
click.echo("Deployer secret must be 32 bytes.") click.echo("Deployer secret must be 32 bytes.")
if click.prompt("Try again?"): if click.prompt("Try again?"):
return __collect_secret() return __collect_secret_hash()
if secret != secret_confirmation:
click.echo("Secrets did not match")
if click.prompt("Try again?"):
return __collect_secret()
else: else:
raise click.Abort() raise click.Abort()
__deployer_init_args.update({'secret_hash': secret}) __deployer_init_args.update({'secret_hash': secret_hash})
return secret return secret
__collect_secret() __collect_secret_hash()
__deployer = deployer_class(**__deployer_init_args) __deployer = deployer_class(**__deployer_init_args)
@ -929,10 +942,9 @@ def deploy(config,
click.echo("{}:{}".format(tx_name, txhash)) click.echo("{}:{}".format(tx_name, txhash))
if not force and click.confirm("Save transaction hashes to JSON file?"): if not force and click.confirm("Save transaction hashes to JSON file?"):
filepath = click.prompt("Enter output filepath", type=click.Path()) file = click.prompt("Enter output filepath", type=click.File(mode='w')) # TODO
with open(filepath, 'w') as file: file.write(json.dumps(__deployment_transactions))
file.write(json.dumps(__deployment_transactions)) click.echo("Successfully wrote transaction hashes file to {}".format(file.path))
click.echo("Successfully wrote transaction hashes file to {}".format(filepath))
@cli.command() @cli.command()
@ -998,13 +1010,15 @@ def status(config,
@cli.command() @cli.command()
@click.option('--rest-host', type=str) @click.option('--rest-host', type=click.STRING)
@click.option('--rest-port', type=int) @click.option('--rest-port', type=click.IntRange(min=49151, max=65535, clamp=False))
@click.option('--db-name', type=str) @click.option('--db-name', type=click.STRING)
@click.option('--checksum-address', type=str) @click.option('--checksum-address', type=CHECKSUM_ADDRESS)
@click.option('--stake-amount', type=int) @click.option('--stake-amount', type=click.IntRange(min=MIN_ALLOWED_LOCKED, max=MIN_ALLOWED_LOCKED, clamp=False))
@click.option('--stake-periods', type=int) @click.option('--stake-periods', type=click.IntRange(min=MIN_LOCKED_PERIODS, max=MAX_MINTING_PERIODS, clamp=False))
@click.option('--resume', is_flag=True) @click.option('--resume', help="Resume an existing stake", is_flag=True)
@click.option('--no-reactor', help="Development feature", is_flag=True)
@click.option('--password', help="Password to unlock Ursula's keyring", prompt=True, hide_input=True, confirmation_prompt=True)
@click.argument('action') @click.argument('action')
@uses_config @uses_config
def ursula(config, def ursula(config,
@ -1015,10 +1029,12 @@ def ursula(config,
checksum_address, checksum_address,
stake_amount, stake_amount,
stake_periods, stake_periods,
resume # TODO Implement stake resume resume, # TODO Implement stake resume
no_reactor,
password
) -> None: ) -> None:
""" """
Manage and Run Ursula Nodes. Manage and run an Ursula node
Here is the procedure to "spin-up" an Ursula node. Here is the procedure to "spin-up" an Ursula node.
@ -1057,12 +1073,13 @@ def ursula(config,
abort_on_learning_error=config.dev) abort_on_learning_error=config.dev)
try: try:
passphrase = click.prompt("Enter passphrase to unlock account", type=str) URSULA = ursula_config.produce(passphrase=password) # 2
URSULA = ursula_config.produce(passphrase=passphrase) # 2
if not config.federated_only: if not config.federated_only:
URSULA.stake(amount=stake_amount, # 3 URSULA.stake(amount=stake_amount, # 3
lock_periods=stake_periods) lock_periods=stake_periods)
URSULA.get_deployer().run() # 4
if not no_reactor:
URSULA.get_deployer().run() # 4
finally: finally:
click.echo("Cleaning up.") click.echo("Cleaning up.")

View File

@ -6,7 +6,6 @@ import shutil
from click.testing import CliRunner from click.testing import CliRunner
from cli.main import cli from cli.main import cli
from nucypher.config.constants import DEFAULT_CONFIG_ROOT, DEFAULT_CONFIG_FILE_LOCATION
from nucypher.config.node import NodeConfiguration from nucypher.config.node import NodeConfiguration
@ -22,12 +21,12 @@ def test_initialize_configuration_directory(custom_filepath):
runner = CliRunner() runner = CliRunner()
# Use the system temporary storage area # Use the system temporary storage area
result = runner.invoke(cli, ['--dev', 'configure', 'init', '--no-registry'], input='Y', catch_exceptions=False) result = runner.invoke(cli, ['--dev', 'configure', 'install', '--no-registry'], input='Y', catch_exceptions=False)
assert '/tmp' in result.output, "Configuration not in system temporary directory" assert '/tmp' in result.output, "Configuration not in system temporary directory"
assert NodeConfiguration._NodeConfiguration__TEMP_CONFIGURATION_DIR_PREFIX in result.output assert NodeConfiguration._NodeConfiguration__TEMP_CONFIGURATION_DIR_PREFIX in result.output
assert result.exit_code == 0 assert result.exit_code == 0
args = [ '--config-root', custom_filepath, 'configure', 'init', '--no-registry'] args = [ '--config-root', custom_filepath, 'configure', 'install', '--no-registry']
result = runner.invoke(cli, args, input='Y', catch_exceptions=False) result = runner.invoke(cli, args, input='Y', catch_exceptions=False)
assert '[y/N]' in result.output, "'configure init' did not prompt the user before attempting to write files" assert '[y/N]' in result.output, "'configure init' did not prompt the user before attempting to write files"
assert '/tmp' in result.output, "Configuration not in system temporary directory" assert '/tmp' in result.output, "Configuration not in system temporary directory"
@ -55,7 +54,7 @@ def test_initialize_configuration_directory(custom_filepath):
def test_validate_runtime_filepaths(custom_filepath): def test_validate_runtime_filepaths(custom_filepath):
runner = CliRunner() runner = CliRunner()
args = ['--config-root', custom_filepath, 'configure', 'init', '--no-registry'] args = ['--config-root', custom_filepath, 'configure', 'install', '--no-registry']
result = runner.invoke(cli, args, input='Y', catch_exceptions=False) result = runner.invoke(cli, args, input='Y', catch_exceptions=False)
result = runner.invoke(cli, ['--config-root', custom_filepath, result = runner.invoke(cli, ['--config-root', custom_filepath,
'configure', 'validate', 'configure', 'validate',

View File

@ -11,7 +11,7 @@ from nucypher.characters.base import Learner
from nucypher.utilities.sandbox.constants import TEST_URSULA_INSECURE_DEVELOPMENT_PASSWORD from nucypher.utilities.sandbox.constants import TEST_URSULA_INSECURE_DEVELOPMENT_PASSWORD
@pytest.mark.skip(reason="Handle second call to reactor.run, or use multiproc") @pytest.mark.skip()
@pytest_twisted.inlineCallbacks @pytest_twisted.inlineCallbacks
def test_run_lone_federated_default_ursula(): def test_run_lone_federated_default_ursula():
@ -19,11 +19,12 @@ def test_run_lone_federated_default_ursula():
'--federated-only', '--federated-only',
'ursula', 'run', 'ursula', 'run',
'--rest-port', '9999', # TODO: use different port to avoid premature ConnectionError with many test runs? '--rest-port', '9999', # TODO: use different port to avoid premature ConnectionError with many test runs?
'--no-reactor'
] ]
runner = CliRunner() runner = CliRunner()
result = yield threads.deferToThread(runner.invoke(cli, args, catch_exceptions=False, input=TEST_URSULA_INSECURE_DEVELOPMENT_PASSWORD)) result = yield threads.deferToThread(runner.invoke, cli, args, catch_exceptions=False, input=TEST_URSULA_INSECURE_DEVELOPMENT_PASSWORD+'\n')
# result = runner.invoke(cli, args, catch_exceptions=False) # TODO: Handle second call to reactor.run
alone = "WARNING - Can't learn right now: Need some nodes to start learning from." alone = "WARNING - Can't learn right now: Need some nodes to start learning from."
time.sleep(Learner._SHORT_LEARNING_DELAY) time.sleep(Learner._SHORT_LEARNING_DELAY)
assert alone in result.output assert alone in result.output