Skip to content

Commit

Permalink
Merge pull request #293 from iamdefinitelyahuman/cli-accounts
Browse files Browse the repository at this point in the history
Add brownie accounts to CLI
  • Loading branch information
iamdefinitelyahuman authored Jan 2, 2020
2 parents 4ec5dca + 99e1ff0 commit d291a19
Show file tree
Hide file tree
Showing 6 changed files with 239 additions and 11 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This changelog format is based on [Keep a Changelog](https://keepachangelog.com/
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased](https://github.com/iamdefinitelyahuman/brownie)
### Added
- `brownie accounts` commandline interface

## [1.3.2](https://github.com/iamdefinitelyahuman/brownie/tree/v1.3.2) - 2020-01-01
### Added
Expand Down
7 changes: 4 additions & 3 deletions brownie/_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@
__doc__ = """Usage: brownie <command> [<args>...] [options <args>]
Commands:
init Initialize a new brownie project
bake Initialize from a brownie-mix template
ethpm Commands related to the ethPM package manager
compile Compiles the contract source files
console Load the console
ethpm Commands related to the ethPM package manager
gui Load the GUI to view opcodes and test coverage
init Initialize a new brownie project
run Run a script in the /scripts folder
accounts Manage local accounts
gui Load the GUI to view opcodes and test coverage
analyze Find security vulnerabilities using the MythX API
Options:
Expand Down
125 changes: 125 additions & 0 deletions brownie/_cli/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/python3

import json
import shutil
import sys
from pathlib import Path

from docopt import docopt

from brownie import accounts
from brownie._config import _get_data_folder
from brownie.convert import to_address
from brownie.utils import color, notify

__doc__ = """Usage: brownie accounts <command> [<arguments> ...] [options]
Commands:
list List available accounts
new <id> Add a new account by entering a private key
generate <id> Add a new account with a random private key
import <id> <path> Import a new account via a keystore file
export <id> <path> Export an existing account keystore file
password <id> Change the password of an existing account
delete <id> Delete an existing account
Options:
--help -h Display this message
Command-line helper for managing local accounts. You can unlock local accounts from
scripts or the console using the Accounts.load method.
"""


def main():
args = docopt(__doc__)
try:
fn = getattr(sys.modules[__name__], f"_{args['<command>']}")
except AttributeError:
print("Invalid command. Try brownie accounts --help")
return
try:
fn(*args["<arguments>"])
except TypeError:
print(f"Invalid arguments for command '{args['<command>']}'. Try brownie ethpm --help")
return


def _list():
account_paths = sorted(_get_data_folder().glob("accounts/*.json"))
print(f"Found {len(account_paths)} account{'s' if len(account_paths)!=1 else ''}:")
for path in account_paths:
u = "\u2514" if path == account_paths[-1] else "\u251c"
with path.open() as fp:
data = json.load(fp)
print(
f" {color('bright black')}{u}\u2500{color('bright blue')}{path.stem}{color}"
f": {color('bright magenta')}{to_address(data['address'])}{color}"
)


def _new(id_):
pk = input("Enter the private key you wish to add: ")
a = accounts.add(pk)
a.save(id_)
notify(
"SUCCESS",
f"A new account '{color('bright magenta')}{a.address}{color}'"
f" has been generated with the id '{color('bright blue')}{id_}{color}'",
)


def _generate(id_):
print("Generating a new private key...")
a = accounts.add()
a.save(id_)
notify(
"SUCCESS",
f"A new account '{color('bright magenta')}{a.address}{color}'"
f" has been generated with the id '{color('bright blue')}{id_}{color}'",
)


def _import(id_, path):
source_path = Path(path).absolute()
if not source_path.suffix:
source_path = source_path.with_suffix(".json")
dest_path = _get_data_folder().joinpath(f"accounts/{id_}.json")
if dest_path.exists():
raise FileExistsError(f"A keystore file already exists with the id '{id_}'")
accounts.load(source_path)
shutil.copy(source_path, dest_path)
notify(
"SUCCESS",
f"Keystore '{color('bright magenta')}{source_path}{color}'"
f" has been imported with the id '{color('bright blue')}{id_}{color}'",
)


def _export(id_, path):
source_path = _get_data_folder().joinpath(f"accounts/{id_}.json")
if not source_path.exists():
raise FileNotFoundError(f"No keystore exists with the id '{id_}'")
dest_path = Path(path).absolute()
if not dest_path.suffix:
dest_path = dest_path.with_suffix(".json")
if dest_path.exists():
raise FileExistsError(f"Export path {dest_path} already exists")
shutil.copy(source_path, dest_path)
notify(
"SUCCESS",
f"Account with id '{color('bright blue')}{id_}{color}' has been"
f" exported to keystore '{color('bright magenta')}{dest_path}{color}'",
)


def _password(id_):
a = accounts.load(id_)
a.save(id_, overwrite=True)
notify("SUCCESS", f"Password has been changed for account '{color('bright blue')}{id_}{color}'")


def _delete(id_):
path = _get_data_folder().joinpath(f"accounts/{id_}.json")
path.unlink()
notify("SUCCESS", f"Account '{color('bright blue')}{id_}{color}' has been deleted")
2 changes: 1 addition & 1 deletion docs/build-folder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ See :ref:`tests-coverage-map-indexes` for more information.
Deployment Artifacts
====================

Each time a contract is deployed to a network where :ref:`persistence<nonlocal-networks-contracts>` is enabled, Brownie saves a copy of the :ref`compiler artifact<build-folder-compiler>`_ used for deployment. In this way accurate deployment data is maintained even if the contract's source code is later modified.
Each time a contract is deployed to a network where :ref:`persistence<nonlocal-networks-contracts>` is enabled, Brownie saves a copy of the :ref:`compiler artifact<build-folder-compiler>`_ used for deployment. In this way accurate deployment data is maintained even if the contract's source code is later modified.

Deployment artifacts are stored at:

Expand Down
23 changes: 16 additions & 7 deletions docs/nonlocal-networks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,25 @@ Accounts

Brownie will automatically load any unlocked accounts returned by a node. If you are using your own private node, you will be able to access your accounts in the same way you would in a local environment.

When connected to a hosted node such as Infura, local accounts must be added via the ``Accounts.add`` method:
In order to use accounts when connected to a hosted node, you must make them available locally. This is done via ``brownie accounts`` in the command line:

.. code-block:: python
::

$ brownie accounts --help
Brownie v1.3.2 - Python development framework for Ethereum

Usage: brownie accounts <command> [<arguments> ...] [options]

>>> accounts.add('8fa2fdfb89003176a16b707fc860d0881da0d1d8248af210df12d37860996fb2')
<Account object '0xc1826925377b4103cC92DeeCDF6F96A03142F37a'>
>>> accounts[0].balance()
17722750299000000000
Commands:
list List available accounts
new <id> Add a new account by entering a private key
generate <id> Add a new account with a random private key
import <id> <path> Import a new account via a keystore file
export <id> <path> Export an existing account keystore file
password <id> Change the password for an account
delete <id> Delete an account

Once an account is added to the ``Accounts`` object, use :ref:`Account.save <api-network-accounts-load>` to save the it to an encrypted keystore, and :ref:`Accounts.load <api-network-accounts-load>` to open it for subsequent use.
After an account has been added, it can be accessed in the console or a script through :ref:`Accounts.load <api-network-accounts-load>`.

Transactions
------------
Expand Down
91 changes: 91 additions & 0 deletions tests/cli/test_cli_accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/python3

import json

import pytest

from brownie._cli import accounts as cli_accounts
from brownie._config import _get_data_folder


@pytest.fixture(autouse=True)
def no_pass(monkeypatch):
monkeypatch.setattr("brownie.network.account.getpass", lambda x: "")


def test_new_account(capfd, monkeypatch):
assert not _get_data_folder().joinpath("accounts/new.json").exists()
cli_accounts._list()
assert "0x3b9b63C67838C9fef6c50e3a06f574Be12b3D50f" not in capfd.readouterr()[0]
monkeypatch.setattr(
"builtins.input",
lambda x: "0x60285766d7296d0744450696603d9593513958fdbb494cc6d234a5a797a2108f",
)
cli_accounts._new("new")
capfd.readouterr()
cli_accounts._list()
assert "0x3b9b63C67838C9fef6c50e3a06f574Be12b3D50f" in capfd.readouterr()[0]
assert _get_data_folder().joinpath("accounts/new.json").exists()


def test_generate(capfd):
path = _get_data_folder().joinpath("accounts/generate.json")
assert not path.exists()
cli_accounts._generate("generate")
assert path.exists()
with path.open() as fp:
data = json.load(fp)
capfd.readouterr()
cli_accounts._list()
assert data["address"] in capfd.readouterr()[0].lower()


def test_import():
path = _get_data_folder().joinpath("accounts/import-test.json")
cli_accounts._generate("import-test")
new_path = _get_data_folder().joinpath("x.json")
path.rename(new_path)
cli_accounts._import("import-new", str(new_path.absolute()))
assert _get_data_folder().joinpath("accounts/import-new.json").exists()


def test_import_already_exists():
cli_accounts._generate("import-exists")
with pytest.raises(FileExistsError):
cli_accounts._import("import-exists", "")


def test_export():
cli_accounts._generate("export-test")
target_path = _get_data_folder().joinpath("exported.json")
assert not target_path.exists()
cli_accounts._export("export-test", str(target_path.absolute()))
assert target_path.exists()


def test_export_not_exists():
with pytest.raises(FileNotFoundError):
cli_accounts._export("unknown", "")


def test_export_overwrite():
cli_accounts._generate("export-exists")
path = str(_get_data_folder().joinpath("accounts/export-exists.json").absolute())
with pytest.raises(FileExistsError):
cli_accounts._export("export-exists", path)


def test_password(monkeypatch, accounts):
cli_accounts._generate("pw-test")
passwords = ["xxx", "xxx", ""]
monkeypatch.setattr("brownie.network.account.getpass", lambda x: passwords.pop())
cli_accounts._password("pw-test")
accounts.load("pw-test")


def test_delete():
cli_accounts._generate("del-test")
path = _get_data_folder().joinpath("accounts/del-test.json")
assert path.exists()
cli_accounts._delete("del-test")
assert not path.exists()

0 comments on commit d291a19

Please sign in to comment.