You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
489 lines
16 KiB
489 lines
16 KiB
"""Wrapper for Harmony's CLI.
|
|
|
|
This module makes it easy for one to interact with the Harmony CLI.
|
|
It also natively manages all of the keystore related features to help with scripting.
|
|
|
|
Example:
|
|
Below is a demo of how to import, manage keys, and interact with the CLI::
|
|
>>> from pyhmy import cli
|
|
>>> cli.single_call("hmy keys add test1")
|
|
'**Important** write this seed phrase in a safe place,
|
|
it is the only way to recover your account if you ever forget your password
|
|
craft ... tobacco'
|
|
>>> cli.get_accounts_keystore()
|
|
{'test1': 'one1aqfeed538xf7n0cfh60tjaeat7yw333pmj6sfu'}
|
|
>>> check_addr = cli.get_accounts_keystore()["test1"]
|
|
>>> cli.get_accounts(check_addr)
|
|
['test1']
|
|
>>> cli.single_call("hmy keys list", timeout=2)
|
|
'NAME \t\t ADDRESS\n\ntest1 \tone1aqfeed538xf7n0cfh60tjaeat7yw333pmj6sfu\n'
|
|
>>> cli.get_accounts_keystore()
|
|
{}
|
|
|
|
This module refers to `accounts` as the NAME/ALIAS of an `address` given to by the
|
|
CLI's account keystore.
|
|
|
|
Example:
|
|
Below is a demo of how to set the CLI binary used by the module::
|
|
>>> import os
|
|
>>> env = cli.download("./bin/test", replace=False)
|
|
>>> cli.environment.update(env)
|
|
>>> new_path = os.getcwd() + "/bin/test"
|
|
>>> new_path
|
|
'/Users/danielvdm/go/src/github.com/harmony-one/pyhmy/bin/test'
|
|
>>> from pyhmy import cli
|
|
>>> cli.set_binary(new_path)
|
|
True
|
|
>>> cli.get_binary_path()
|
|
'/Users/danielvdm/go/src/github.com/harmony-one/pyhmy/bin/test'
|
|
|
|
For more details, reference the documentation here: TODO gitbook docs
|
|
"""
|
|
|
|
import subprocess
|
|
import os
|
|
import shutil
|
|
import re
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
import pexpect
|
|
|
|
import requests
|
|
|
|
from .util import get_bls_build_variables, get_gopath
|
|
|
|
if sys.platform.startswith( "linux" ):
|
|
_libs = {
|
|
"libbls384_256.so",
|
|
"libcrypto.so.10",
|
|
"libgmp.so.10",
|
|
"libgmpxx.so.4",
|
|
"libmcl.so",
|
|
}
|
|
else:
|
|
_libs = {
|
|
"libbls384_256.dylib",
|
|
"libcrypto.1.0.0.dylib",
|
|
"libgmp.10.dylib",
|
|
"libgmpxx.4.dylib",
|
|
"libmcl.dylib",
|
|
}
|
|
# Internal accounts keystore, make sure to sync when needed.
|
|
_accounts = {}
|
|
# Internal path to account keystore, will match the current binary.
|
|
ARG_PREFIX = "__PYHMY_ARG_PREFIX__"
|
|
# _keystore_cache_lock = Lock()
|
|
|
|
environment = os.environ.copy() # The environment for the CLI to execute in.
|
|
|
|
# completely remove caching...
|
|
# we need to improve getting address better internally to REDUCE single calls....
|
|
# def _cache_and_lock_accounts_keystore(fn):
|
|
# """Internal decorator to cache the accounts keystore and prevent concurrent
|
|
# accesses with locks."""
|
|
# cached_accounts = {}
|
|
# last_mod = None
|
|
|
|
# def wrap(*args):
|
|
# nonlocal last_mod
|
|
# _keystore_cache_lock.acquire()
|
|
# files_in_dir = str(os.listdir(ACCOUNT_KEYSTORE_PATH))
|
|
# dir_mod_time = str(os.path.getmtime(ACCOUNT_KEYSTORE_PATH))
|
|
# curr_mod = hash(files_in_dir + dir_mod_time + BINARY_PATH)
|
|
# if curr_mod != last_mod:
|
|
# cached_accounts.clear()
|
|
# cached_accounts.update(fn(*args))
|
|
# last_mod = curr_mod
|
|
# accounts = cached_accounts.copy()
|
|
# _keystore_cache_lock.release()
|
|
# return accounts
|
|
|
|
# return wrap
|
|
|
|
|
|
def account_keystore_path( value = None ):
|
|
"""
|
|
Gets or sets the ACCOUNT_KEYSTORE_PATH
|
|
"""
|
|
if "value" not in account_keystore_path.__dict__:
|
|
account_keystore_path.value = "~/.hmy/account-keys"
|
|
if value:
|
|
account_keystore_path.value = value
|
|
return account_keystore_path.value
|
|
|
|
|
|
def binary_path( value = None ):
|
|
"""
|
|
Gets or sets the BINARY_PATH
|
|
"""
|
|
if "value" not in binary_path.__dict__:
|
|
binary_path.value = "hmy"
|
|
if value:
|
|
binary_path.value = value
|
|
return binary_path.value
|
|
|
|
|
|
def _get_current_accounts_keystore():
|
|
"""Internal function that gets the current keystore from the CLI.
|
|
|
|
:returns A dictionary where the keys are the account names/aliases and the
|
|
values are their 'one1...' addresses.
|
|
"""
|
|
curr_addresses = {}
|
|
response = single_call( "hmy keys list" )
|
|
lines = response.split( "\n" )
|
|
if "NAME" not in lines[ 0 ] or "ADDRESS" not in lines[ 0 ]:
|
|
raise ValueError(
|
|
"Name or Address not found on first line of key list"
|
|
)
|
|
if lines[ 1 ] != "":
|
|
raise ValueError(
|
|
"Unknown format: No blank line between label and data"
|
|
)
|
|
for line in lines[ 2 : ]:
|
|
columns = line.split( "\t" )
|
|
if len( columns ) != 2:
|
|
break # Done iterating through all of the addresses.
|
|
name, address = columns
|
|
curr_addresses[ name.strip() ] = address
|
|
return curr_addresses
|
|
|
|
|
|
def _set_account_keystore_path():
|
|
"""Internal function to set the account keystore path according to the
|
|
binary."""
|
|
response = single_call( "hmy keys location" ).strip()
|
|
if not os.path.exists( response ):
|
|
os.mkdir( response )
|
|
account_keystore_path( response )
|
|
|
|
|
|
def _sync_accounts():
|
|
"""Internal function that UPDATES the accounts keystore with the CLI's
|
|
keystore."""
|
|
new_keystore = _get_current_accounts_keystore()
|
|
for key, value in new_keystore.items():
|
|
if key not in _accounts:
|
|
_accounts[ key ] = value
|
|
acc_keys_to_remove = [ k for k in _accounts if k not in new_keystore ]
|
|
for key in acc_keys_to_remove:
|
|
del _accounts[ key ]
|
|
|
|
|
|
def _make_call_command( command ):
|
|
"""Internal function that processes a command String or String Arg List for
|
|
underlying pexpect or subprocess call.
|
|
|
|
Note that single quote is not respected for strings.
|
|
"""
|
|
if isinstance( command, list ):
|
|
command_toks = command
|
|
else:
|
|
all_strings = sorted(
|
|
re.findall(r'"(.*?)"', command),
|
|
key=lambda e: len(e), # pylint: disable=unnecessary-lambda
|
|
reverse=True
|
|
)
|
|
for i, string in enumerate( all_strings ):
|
|
command = command.replace( string, f"{ARG_PREFIX}_{i}" )
|
|
command_toks_prefix = [ el for el in command.split( " " ) if el ]
|
|
command_toks = []
|
|
for element in command_toks_prefix:
|
|
if element.startswith( f'"{ARG_PREFIX}_'
|
|
) and element.endswith( '"' ):
|
|
index = int(
|
|
element.replace( f'"{ARG_PREFIX}_',
|
|
"" ).replace( '"',
|
|
"" )
|
|
)
|
|
command_toks.append( all_strings[ index ] )
|
|
else:
|
|
command_toks.append( element )
|
|
if re.match( ".*hmy", command_toks[ 0 ] ):
|
|
command_toks = command_toks[ 1 : ]
|
|
return command_toks
|
|
|
|
|
|
def get_accounts_keystore():
|
|
"""
|
|
:returns A dictionary where the keys are the account names/aliases and the
|
|
values are their 'one1...' addresses. The returned dictionary
|
|
will be maintained as keys gets added and removed.
|
|
"""
|
|
_sync_accounts()
|
|
return _accounts
|
|
|
|
|
|
def is_valid_binary( path ):
|
|
"""
|
|
:param path: Path to the Harmony CLI binary (absolute or relative).
|
|
:return: If the file at the path is a CLI binary.
|
|
"""
|
|
path = os.path.realpath( path )
|
|
os.chmod( path, os.stat( path ).st_mode | stat.S_IEXEC )
|
|
try:
|
|
with subprocess.Popen(
|
|
[ path,
|
|
"version" ],
|
|
env = environment,
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.PIPE,
|
|
) as proc:
|
|
_, err = proc.communicate()
|
|
if not err:
|
|
return False
|
|
return "harmony" in err.decode().strip().lower()
|
|
except (
|
|
OSError,
|
|
subprocess.CalledProcessError,
|
|
subprocess.SubprocessError
|
|
):
|
|
return False
|
|
|
|
|
|
def set_binary( path ):
|
|
"""
|
|
:param path: The path of the CLI binary to use.
|
|
:returns If the binary has been set.
|
|
|
|
Note that the exposed keystore will be updated accordingly.
|
|
"""
|
|
path = os.path.realpath( path )
|
|
assert os.path.exists( path )
|
|
os.chmod( path, os.stat( path ).st_mode | stat.S_IEXEC )
|
|
if not is_valid_binary( path ):
|
|
return False
|
|
binary_path( path )
|
|
_set_account_keystore_path()
|
|
_sync_accounts()
|
|
return True
|
|
|
|
|
|
def get_binary_path():
|
|
"""
|
|
:return: The absolute path of the CLI binary.
|
|
"""
|
|
return os.path.abspath( binary_path() )
|
|
|
|
|
|
def get_version():
|
|
"""
|
|
:return: The version string of the CLI binary.
|
|
"""
|
|
with subprocess.Popen(
|
|
[ binary_path(),
|
|
"version" ],
|
|
env = environment,
|
|
stdout = subprocess.PIPE,
|
|
stderr = subprocess.PIPE,
|
|
) as proc:
|
|
_, err = proc.communicate()
|
|
if not err:
|
|
raise RuntimeError(
|
|
f"Could not get version.\n"
|
|
f"\tGot exit code {proc.returncode}. Expected non-empty error message."
|
|
)
|
|
return err.decode().strip()
|
|
|
|
|
|
def get_account_keystore_path():
|
|
"""
|
|
:return: The absolute path to the account keystore of the CLI binary.
|
|
"""
|
|
return os.path.abspath( account_keystore_path() )
|
|
|
|
|
|
def check_address( address ):
|
|
"""
|
|
:param address: A 'one1...' address.
|
|
:return: Boolean of if the address is in the CLI's keystore.
|
|
"""
|
|
return address in get_accounts_keystore().values()
|
|
|
|
|
|
def get_address( name ):
|
|
"""
|
|
:param name: The alias of a key used in the CLI's keystore.
|
|
:return: The associated 'one1...' address.
|
|
"""
|
|
return get_accounts_keystore().get( name, None )
|
|
|
|
|
|
def get_accounts( address ):
|
|
"""
|
|
:param address: The 'one1...' address
|
|
:return: A list of account names associated with the param
|
|
|
|
Note that a list of account names is needed because 1 address can
|
|
have multiple names within the CLI's keystore.
|
|
"""
|
|
return [
|
|
acc for acc,
|
|
addr in get_accounts_keystore().items() if address == addr
|
|
]
|
|
|
|
|
|
def remove_account( name ):
|
|
"""Note that this edits the keystore directly since there is currently no
|
|
way to remove an address using the CLI.
|
|
|
|
:param name: The alias of a key used in the CLI's keystore.
|
|
:raises RuntimeError: If it failed to remove an account.
|
|
"""
|
|
if not get_address( name ):
|
|
return
|
|
keystore_path = f"{get_account_keystore_path()}/{name}"
|
|
try:
|
|
shutil.rmtree( keystore_path )
|
|
except ( shutil.Error, FileNotFoundError ) as err:
|
|
raise RuntimeError(
|
|
f"Failed to delete dir: {keystore_path}\n"
|
|
f"\tException: {err}"
|
|
) from err
|
|
_sync_accounts()
|
|
|
|
|
|
def remove_address( address ):
|
|
"""
|
|
:param address: The 'one1...' address to be removed.
|
|
"""
|
|
for name in get_accounts( address ):
|
|
remove_account( name )
|
|
_sync_accounts()
|
|
|
|
|
|
def single_call( command, timeout = 60, error_ok = False ):
|
|
"""
|
|
:param command: String or String Arg List of command to execute on CLI.
|
|
:param timeout: Optional timeout in seconds
|
|
:param error_ok: Optional flag to allow errors and return whatever possible
|
|
:returns: Decoded string of response from hmy CLI call
|
|
:raises: RuntimeError if bad command
|
|
"""
|
|
command_toks = [ binary_path() ] + _make_call_command( command )
|
|
try:
|
|
return subprocess.check_output(
|
|
command_toks,
|
|
env = environment,
|
|
timeout = timeout
|
|
).decode()
|
|
except subprocess.CalledProcessError as err:
|
|
if not error_ok:
|
|
raise RuntimeError(
|
|
f"Bad CLI args: `{command}`\n "
|
|
f"\tException: {err}"
|
|
) from err
|
|
return err.output.decode()
|
|
|
|
|
|
def expect_call( command, timeout = 60 ):
|
|
"""
|
|
:param command: String or String Arg List of command to execute on CLI.
|
|
:param timeout: Optional timeout in seconds
|
|
:returns: A pexpect child program
|
|
:raises: RuntimeError if bad command
|
|
"""
|
|
command_toks = _make_call_command( command )
|
|
try:
|
|
proc = pexpect.spawn(
|
|
f"{binary_path()}",
|
|
command_toks,
|
|
env = environment,
|
|
timeout = timeout
|
|
)
|
|
proc.delaybeforesend = None
|
|
except pexpect.ExceptionPexpect as err:
|
|
raise RuntimeError(
|
|
f"Bad CLI args: `{command}`\n "
|
|
f"\tException: {err}"
|
|
) from err
|
|
return proc
|
|
|
|
|
|
def download( path = "./bin/hmy", replace = True, verbose = True ):
|
|
"""Download the CLI binary to the specified path. Related files will be
|
|
saved in the same directory.
|
|
|
|
:param path: The desired path (absolute or relative) of the saved binary.
|
|
:param replace: A flag to force a replacement of the binary/file.
|
|
:param verbose: A flag to enable a report message once the binary is downloaded.
|
|
:returns the environment to run the saved CLI binary.
|
|
"""
|
|
path = os.path.realpath( path )
|
|
parent_dir = Path( path ).parent
|
|
assert not os.path.isdir(
|
|
path
|
|
), f"path `{path}` must specify a file, not a directory."
|
|
|
|
if not os.path.exists( path ) or replace:
|
|
old_cwd = os.getcwd()
|
|
os.makedirs( parent_dir, exist_ok = True )
|
|
os.chdir( parent_dir )
|
|
hmy_script_path = os.path.join( parent_dir, "hmy.sh" )
|
|
with open( hmy_script_path, "w", encoding = 'utf8' ) as script_file:
|
|
script_file.write(
|
|
requests.get(
|
|
"https://raw.githubusercontent.com/harmony-one/go-sdk/master/scripts/hmy.sh"
|
|
).content.decode()
|
|
)
|
|
os.chmod(
|
|
hmy_script_path,
|
|
os.stat( hmy_script_path ).st_mode | stat.S_IEXEC
|
|
)
|
|
same_name_file = False
|
|
if (
|
|
os.path.exists( os.path.join( parent_dir,
|
|
"hmy" ) ) and
|
|
Path( path ).name != "hmy"
|
|
): # Save same name file.
|
|
same_name_file = True
|
|
os.rename(
|
|
os.path.join( parent_dir,
|
|
"hmy" ),
|
|
os.path.join( parent_dir,
|
|
".hmy_tmp" )
|
|
)
|
|
if verbose:
|
|
subprocess.call( [ hmy_script_path, "-d" ] )
|
|
else:
|
|
with open( os.devnull, "w", encoding = "UTF-8" ) as devnull:
|
|
subprocess.call(
|
|
[ hmy_script_path,
|
|
"-d" ],
|
|
stdout = devnull,
|
|
stderr = subprocess.STDOUT,
|
|
)
|
|
os.rename( os.path.join( parent_dir, "hmy" ), path )
|
|
if same_name_file:
|
|
os.rename(
|
|
os.path.join( parent_dir,
|
|
".hmy_tmp" ),
|
|
os.path.join( parent_dir,
|
|
"hmy" )
|
|
)
|
|
if verbose:
|
|
print( f"Saved harmony binary to: `{path}`" )
|
|
os.chdir( old_cwd )
|
|
|
|
env = os.environ.copy()
|
|
if sys.platform.startswith( "darwin" ): # Dynamic linking for darwin
|
|
try:
|
|
files_in_parent_dir = set( os.listdir( parent_dir ) )
|
|
if files_in_parent_dir.intersection( _libs ) == _libs:
|
|
env[ "DYLD_FALLBACK_LIBRARY_PATH" ] = parent_dir
|
|
elif os.path.exists(
|
|
f"{get_gopath()}/src/github.com/harmony-one/bls"
|
|
) and os.path.exists(
|
|
f"{get_gopath()}/src/github.com/harmony-one/mcl"
|
|
):
|
|
env.update( get_bls_build_variables() )
|
|
else:
|
|
raise RuntimeWarning(
|
|
f"Could not get environment for downloaded hmy CLI at `{path}`"
|
|
)
|
|
except Exception as exception:
|
|
raise RuntimeWarning(
|
|
f"Could not get environment for downloaded hmy CLI at `{path}`"
|
|
) from exception
|
|
return env
|
|
|