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.
447 lines
15 KiB
447 lines
15 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
|
|
|