"""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 pexpect import os import shutil import re import stat import sys from multiprocessing import Lock from pathlib import Path 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"} _accounts = {} # Internal accounts keystore, make sure to sync when needed. _account_keystore_path = "~/.hmy/account-keys" # Internal path to account keystore, will match the current binary. _binary_path = "hmy" # Internal binary path. _arg_prefix = "__PYHMY_ARG_PREFIX__" _keystore_cache_lock = Lock() environment = os.environ.copy() # The environment for the CLI to execute in. # TODO: 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 _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. """ global _account_keystore_path 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 in new_keystore.keys(): if key not in _accounts.keys(): _accounts[key] = new_keystore[key] acc_keys_to_remove = [k for k in _accounts.keys() if k not in new_keystore.keys()] 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), 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 el in command_toks_prefix: if el.startswith(f'"{_arg_prefix}_') and el.endswith(f'"'): index = int(el.replace(f'"{_arg_prefix}_', '').replace('"', '')) command_toks.append(all_strings[index]) else: command_toks.append(el) 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: proc = subprocess.Popen([path, "version"], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, 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. """ global _binary_path 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. """ proc = subprocess.Popen([_binary_path, "version"], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, 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') as f: f.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: subprocess.call([hmy_script_path, '-d'], stdout=open(os.devnull, 'w'), 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 e: raise RuntimeWarning(f"Could not get environment for downloaded hmy CLI at `{path}`") from e return env