diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..535a8e16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,17 @@ +* `py-geth` Version: x.x.x +* `go-ethereum` Version: x.x.x +* Python Version: x.x.x +* OS: osx/linux/win + + +### What was wrong? + +Please include information like: + +* full output of the error you received +* what command you ran +* the code that caused the failure + +#### Cute Animal Picture + +> put a cute animal picture here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..4b826137 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +### What was wrong? + + + +### How was it fixed? + + + +#### Cute Animal Picture + +> put a cute animal picture here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..47630865 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Complexity +output/*.html +output/*/index.html + +# Sphinx +docs/_build + +# Blockchain +**/chains/*/nodes/* +**/chains/*/nodekey +**/chains/*/dapp/* +**/chains/*/chaindata/* + +# Known Contracts +**/known_contracts.json diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..e78c9a55 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: python +python: + - "3.5" +dist: trusty +sudo: required +before_install: + - sudo add-apt-repository -y ppa:ethereum/ethereum + - sudo apt-get update + - sudo apt-get install -y ethereum +env: + matrix: + - TOX_ENV=py27 + - TOX_ENV=py34 + - TOX_ENV=py35 + - TOX_ENV=flake8 +cache: pip +install: + - travis_retry pip install setuptools --upgrade + - travis_retry pip install tox +script: + - tox -e $TOX_ENV +after_script: + - cat .tox/$TOX_ENV/log/*.log diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 00000000..0c930ca6 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,4 @@ +0.1.0 +----- + +- Initial Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..dea05b68 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Development + +To start development for Populus you should begin by cloning the repo. + +```bash +$ git clone git@github.com/pipermerriam/py-geth.git +``` + + +# Cute Animal Pictures + +All pull requests need to have a cute animal picture. This is a very important +part of the development process. + + +# Pull Requests + +In general, pull requests are welcome. Please try to adhere to the following. + +- code should conform to PEP8 and as well as the linting done by flake8 +- include tests. +- include any relevant documentation updates. + +It's a good idea to make pull requests early on. A pull request represents the +start of a discussion, and doesn't necessarily need to be the final, finished +submission. + +GitHub's documentation for working on pull requests is [available here][pull-requests]. + +Always run the tests before submitting pull requests, and ideally run `tox` in +order to check that your modifications don't break anything. + +Once you've made a pull request take a look at the travis build status in the +GitHub interface and make sure the tests are runnning as you'd expect. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8c67f6d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Piper Merriam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..c986e73b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include LICENSE +include README.md +include requirements.txt + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] + +include pygeth/default_blockchain_password +include pygeth/genesis.json diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..a2de07a4 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +.PHONY: clean-pyc clean-build + +help: + @echo "clean-build - remove build artifacts" + @echo "clean-pyc - remove Python file artifacts" + @echo "lint - check style with flake8" + @echo "test - run tests quickly with the default Python" + @echo "testall - run tests on every Python version with tox" + @echo "release - package and upload a release" + @echo "sdist - package" + +clean: clean-build clean-pyc + +clean-build: + rm -fr build/ + rm -fr dist/ + rm -fr *.egg-info + +clean-pyc: + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + +lint: + flake8 pygeth + +test: + py.test --tb native tests + +test-all: + tox + +release: clean + python setup.py sdist bdist bdist_wheel upload + +sdist: clean + python setup.py sdist bdist bdist_wheel + ls -l dist diff --git a/README.md b/README.md new file mode 100644 index 00000000..7d240def --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# PyGeth + +[![Build Status](https://travis-ci.org/pipermerriam/py-geth.png)](https://travis-ci.org/pipermerriam/py-geth) +[![Documentation Status](https://readthedocs.org/projects/py-geth/badge/?version=latest)](https://readthedocs.org/projects/py-geth/?badge=latest) +[![PyPi version](https://pypip.in/v/py-geth/badge.png)](https://pypi.python.org/pypi/py-geth) +[![PyPi downloads](https://pypip.in/d/py-geth/badge.png)](https://pypi.python.org/pypi/py-geth) + + +Python wrapper around running `geth` as a subprocess diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..f4fe9b9b --- /dev/null +++ b/conftest.py @@ -0,0 +1,50 @@ +import pytest +import json +import requests + + +@pytest.fixture +def open_port(): + from pygeth.utils import get_open_port + return get_open_port() + + +@pytest.fixture() +def rpc_client(open_port): + from pygeth.utils.encoding import force_obj_to_text + endpoint = "http://127.0.0.1:{port}".format(port=open_port) + + def make_request(method, params=None, raise_on_error=True): + global nonce + nonce += 1 # NOQA + payload = { + "id": nonce, + "jsonrpc": "2.0", + "method": method, + "params": params or [], + } + payload_data = json.dumps(force_obj_to_text(payload)) + response = requests.post(endpoint, data=payload_data) + + if raise_on_error: + assert response.status_code == 200 + + result = response.json() + + if 'error' in result: + raise AssertionError(result['error']) + + assert set(result.keys()) == {"id", "jsonrpc", "result"} + return response.json()['result'] + + return make_request + + +@pytest.fixture() +def data_dir(tmpdir): + return str(tmpdir.mkdir("data-dir")) + + +@pytest.fixture() +def base_dir(tmpdir): + return str(tmpdir.mkdir("base-dir")) diff --git a/pygeth/__init__.py b/pygeth/__init__.py new file mode 100644 index 00000000..bf5073d0 --- /dev/null +++ b/pygeth/__init__.py @@ -0,0 +1,7 @@ +import pkg_resources + +from gevent import monkey +monkey.patch_all() + + +__version__ = pkg_resources.get_distribution("py-geth").version diff --git a/pygeth/accounts.py b/pygeth/accounts.py new file mode 100644 index 00000000..1abffdb1 --- /dev/null +++ b/pygeth/accounts.py @@ -0,0 +1,108 @@ +import os +import re + +from .wrapper import spawn_geth +from .utils.proc import format_error_message +from .chain import ( + get_genesis_file_path, + is_live_chain, + is_testnet_chain, + write_genesis_file, +) + + +def get_accounts(data_dir, **geth_kwargs): + """ + Returns all geth accounts as tuple of hex encoded strings + + >>> geth_accounts() + ... ('0x...', '0x...') + """ + command, proc = spawn_geth(dict( + data_dir=data_dir, + suffix_args=['account', 'list'], + **geth_kwargs + )) + stdoutdata, stderrdata = proc.communicate() + + if proc.returncode: + if "no keys in store" in stderrdata: + return tuple() + else: + raise ValueError(format_error_message( + "Error trying to list accounts", + command, + proc.returncode, + stdoutdata, + stderrdata, + )) + accounts = parse_geth_accounts(stdoutdata) + return accounts + + +account_regex = re.compile(b'\{([a-f0-9]{40})\}') + + +def create_new_account(data_dir, password, **geth_kwargs): + if os.path.exists(password): + geth_kwargs['password'] = password + + command, proc = spawn_geth(dict( + data_dir=data_dir, + suffix_args=['account', 'new'], + **geth_kwargs + )) + + if os.path.exists(password): + stdoutdata, stderrdata = proc.communicate() + else: + stdoutdata, stderrdata = proc.communicate(b"\n".join((password, password))) + + if proc.returncode: + raise ValueError(format_error_message( + "Error trying to create a new account", + command, + proc.returncode, + stdoutdata, + stderrdata, + )) + + match = account_regex.search(stdoutdata) + if not match: + raise ValueError(format_error_message( + "Did not find an address in process output", + command, + proc.returncode, + stdoutdata, + stderrdata, + )) + + return b'0x' + match.groups()[0] + + +def ensure_account_exists(data_dir, **geth_kwargs): + accounts = get_accounts(data_dir, **geth_kwargs) + if not accounts: + account = create_new_account(data_dir, **geth_kwargs) + genesis_file_path = get_genesis_file_path(data_dir) + + should_write_genesis = not any(( + os.path.exists(genesis_file_path), + is_live_chain(data_dir), + is_testnet_chain(data_dir), + )) + if should_write_genesis: + write_genesis_file( + genesis_file_path, + alloc=dict([ + (account, "1000000000000000000000000000"), # 1 billion ether. + ]), + ) + else: + account = accounts[0] + return account + + +def parse_geth_accounts(raw_accounts_output): + accounts = account_regex.findall(raw_accounts_output) + return tuple(b'0x' + account for account in accounts) diff --git a/pygeth/chain.py b/pygeth/chain.py new file mode 100644 index 00000000..c2a38b36 --- /dev/null +++ b/pygeth/chain.py @@ -0,0 +1,108 @@ +import os +import json +import sys + + +from .utils.encoding import ( + force_obj_to_text, +) +from .utils.filesystem import ( + ensure_path_exists, + is_same_path, +) + + +def get_live_data_dir(): + """ + pygeth needs a base directory to store it's chain data. By default this is + the directory that `geth` uses as it's `datadir`. + """ + if sys.platform == 'darwin': + data_dir = os.path.expanduser(os.path.join( + "~", + "Library", + "Ethereum", + )) + elif sys.platform == 'linux2': + data_dir = os.path.expanduser(os.path.join( + "~", + ".ethereum", + )) + elif sys.platform == 'win32': + data_dir = os.path.expanduser(os.path.join( + "\\", + "~", + "AppData", + "Roaming", + "Ethereum", + )) + + else: + raise ValueError( + "Unsupported platform. Only darwin/linux2/win32 are " + "supported. You must specify the geth datadir manually" + ) + return data_dir + + +def get_testnet_data_dir(): + return os.path.abspath(os.path.expanduser(os.path.join( + get_live_data_dir(), + "testnet", + ))) + + +def get_default_base_dir(): + return get_live_data_dir() + + +def get_chain_data_dir(base_dir, name): + data_dir = os.path.abspath(os.path.join(base_dir, name)) + ensure_path_exists(data_dir) + return data_dir + + +def get_genesis_file_path(data_dir): + return os.path.join(data_dir, 'genesis.json') + + +def is_live_chain(data_dir): + return is_same_path(data_dir, get_live_data_dir()) + + +def is_testnet_chain(data_dir): + return is_same_path(data_dir, get_testnet_data_dir()) + + +def write_genesis_file(genesis_file_path, + overwrite=False, + nonce="0xdeadbeefdeadbeef", + timestamp="0x0", + parentHash="0x0000000000000000000000000000000000000000000000000000000000000000", + extraData="0x686f727365", + gasLimit="0x2fefd8", + difficulty="0x400", + mixhash="0x0000000000000000000000000000000000000000000000000000000000000000", + coinbase="0x3333333333333333333333333333333333333333", + alloc=None): + + if os.path.exists(genesis_file_path) and not overwrite: + raise ValueError("Genesis file already present. call with `overwrite=True` to overwrite this file") + + if alloc is None: + alloc = {} + + genesis_data = { + "nonce": nonce, + "timestamp": timestamp, + "parentHash": parentHash, + "extraData": extraData, + "gasLimit": gasLimit, + "difficulty": difficulty, + "mixhash": mixhash, + "coinbase": coinbase, + "alloc": alloc, + } + + with open(genesis_file_path, 'w') as genesis_file: + genesis_file.write(json.dumps(force_obj_to_text(genesis_data))) diff --git a/pygeth/default_blockchain_password b/pygeth/default_blockchain_password new file mode 100644 index 00000000..cdb6a294 --- /dev/null +++ b/pygeth/default_blockchain_password @@ -0,0 +1 @@ +this-is-not-a-secure-password diff --git a/pygeth/genesis.json b/pygeth/genesis.json new file mode 100644 index 00000000..93a73dcf --- /dev/null +++ b/pygeth/genesis.json @@ -0,0 +1,12 @@ +{ + "nonce": "0xdeadbeefdeadbeef", + "timestamp": "0x0", + "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "extraData": "0x686f727365", + "gasLimit": "0x2fefd8", + "difficulty": "0x400", + "mixhash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "coinbase": "0x3333333333333333333333333333333333333333", + "alloc": { + } +} diff --git a/pygeth/geth.py b/pygeth/geth.py new file mode 100644 index 00000000..8e31001a --- /dev/null +++ b/pygeth/geth.py @@ -0,0 +1,124 @@ +import uuid + +from threading import Lock + +from gevent import subprocess + +from .utils.filesystem import ( + is_executable_available, +) +from .utils.proc import ( + kill_proc, +) +from .accounts import ( + ensure_account_exists, + get_accounts, +) +from .wrapper import ( + construct_test_chain_kwargs, + construct_popen_command, + spawn_geth, +) +from .chain import ( + get_default_base_dir, + get_chain_data_dir, + get_live_data_dir, + get_testnet_data_dir, +) + + +class BaseGethProcess(object): + _proc = None + + def __init__(self, geth_kwargs): + self.lock = Lock() + self.geth_kwargs = geth_kwargs + self.command = construct_popen_command(**geth_kwargs) + + def start(self): + self.lock.acquire() + + self._proc = subprocess.Popen( + self.command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def stop(self): + if not self.lock.locked(): + raise ValueError("Not running") + + if self._proc.poll() is None: + kill_proc(self._proc) + + if self._proc.poll() is None: + raise ValueError("Unable to kill process") + else: + self.lock.release() + + @property + def is_started(self): + return self.lock.locked() + + @property + def is_alive(self): + return self.is_started and self._proc.poll() is None + + @property + def is_stopped(self): + return self._proc is not None and self._proc.poll() is not None + + @property + def accounts(self): + return get_accounts(**self.geth_kwargs) + + +class LiveGethProcess(BaseGethProcess): + def __init__(self, geth_kwargs): + if 'data_dir' in geth_kwargs: + raise ValueError("You cannot specify `data_dir` for a LiveGethProcess") + + super(LiveGethProcess, self).__init__(geth_kwargs) + + @property + def data_dir(self): + return get_live_data_dir() + + +class TestnetGethProcess(BaseGethProcess): + def __init__(self, geth_kwargs): + if 'data_dir' in geth_kwargs: + raise ValueError("You cannot specify `data_dir` for a TestnetGethProces") + + extra_kwargs = geth_kwargs.get('extra_kwargs', []) + extra_kwargs.append('--testnet') + + geth_kwargs['extra_kwargs'] = extra_kwargs + + super(TestnetGethProcess, self).__init__(geth_kwargs) + + @property + def data_dir(self): + return get_testnet_data_dir() + + +class DevGethProcess(BaseGethProcess): + def __init__(self, chain_name, base_dir=None, overrides=None): + if overrides is None: + overrides = {} + + if 'data_dir' in overrides: + raise ValueError("You cannot specify `data_dir` for a DevGethProcess") + + if base_dir is None: + base_dir = get_default_base_dir() + + geth_kwargs = construct_test_chain_kwargs(**overrides) + self.data_dir = get_chain_data_dir(base_dir, chain_name) + + ensure_account_exists(self.data_dir, **geth_kwargs) + + geth_kwargs['data_dir'] = self.data_dir + + super(DevGethProcess, self).__init__(geth_kwargs) diff --git a/pygeth/reset.py b/pygeth/reset.py new file mode 100644 index 00000000..bb47d5e2 --- /dev/null +++ b/pygeth/reset.py @@ -0,0 +1,64 @@ +import os + +from .wrapper import ( + construct_test_chain_kwargs, + spawn_geth_subprocess, +) +from .chains import ( + is_live_chain, + is_testnet_chain, +) +from .utils.filesystem import ( + remove_file_if_exists, + remove_dir_if_exists, +) + + +def soft_reset_chain(allow_live=False, allow_testnet=False, **geth_kwargs): + data_dir = geth_kwargs.get('data_dir') + + if data_dir is None or (not allow_live and is_live_chain(data_dir)): + raise ValueError("To reset the live chain you must call this function with `allow_live=True`") + + if not allow_testnet and is_testnet_chain(data_dir): + raise ValueError("To reset the testnet chain you must call this function with `allow_testnet=True`") + + suffix_args = geth_kwargs.pop('suffix_args', []) + suffix_args.extend(( + 'removedb', + )) + + geth_kwargs['suffix_args'] = suffix_args + + _, proc = spawn_geth_subprocess(**geth_kwargs) + + stdoutdata, stderrdata = proc.communicate("y") + + if "Removing chaindata" not in stdoutdata: + raise ValueError("An error occurred while removing the chain:\n\nError:\n{0}\n\nOutput:\n{1}".format(stderrdata, stdoutdata)) + + +def hard_reset_chain(data_dir, allow_live=False, allow_testnet=False): + if not allow_live and is_live_chain(data_dir): + raise ValueError("To reset the live chain you must call this function with `allow_live=True`") + + if not allow_testnet and is_testnet_chain(data_dir): + raise ValueError("To reset the testnet chain you must call this function with `allow_testnet=True`") + + blockchain_dir = os.path.join(data_dir, 'chaindata') + remove_dir_if_exists(blockchain_dir) + + dapp_dir = os.path.join(data_dir, 'dapp') + remove_dir_if_exists(dapp_dir) + + nodekey_path = os.path.join(data_dir, 'nodekey') + remove_file_if_exists(nodekey_path) + + nodes_path = os.path.join(data_dir, 'nodes') + remove_dir_if_exists(nodes_path) + + geth_ipc_path = os.path.join(data_dir, 'geth.ipc') + remove_file_if_exists(geth_ipc_path) + + history_path = os.path.join(data_dir, 'history') + remove_file_if_exists(history_path) diff --git a/pygeth/utils/__init__.py b/pygeth/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pygeth/utils/encoding.py b/pygeth/utils/encoding.py new file mode 100644 index 00000000..dcbb0f2f --- /dev/null +++ b/pygeth/utils/encoding.py @@ -0,0 +1,62 @@ +import sys + + +if sys.version_info.major == 2: + binary_types = (bytes, bytearray) + text_types = (unicode,) # NOQA + string_types = (basestring, bytearray) # NOQA +else: + binary_types = (bytes, bytearray) + text_types = (str,) + string_types = (bytes, str, bytearray) + + +def is_binary(value): + return isinstance(value, binary_types) + + +def is_text(value): + return isinstance(value, text_types) + + +def is_string(value): + return isinstance(value, string_types) + + +if sys.version_info.major == 2: + def force_bytes(value): + if is_binary(value): + return str(value) + elif is_text(value): + return value.encode('latin1') + else: + raise TypeError("Unsupported type: {0}".format(type(value))) + + def force_text(value): + if is_text(value): + return value + elif is_binary(value): + return unicode(force_bytes(value), 'latin1') # NOQA + else: + raise TypeError("Unsupported type: {0}".format(type(value))) +else: + def force_text(value): + if isinstance(value, text_types): + return value + elif isinstance(value, binary_types): + return str(value, 'latin1') + else: + raise TypeError("Unsupported type: {0}".format(type(value))) + + +def force_obj_to_text(obj): + if is_string(obj): + return force_text(obj) + elif isinstance(obj, dict): + return { + force_obj_to_text(k): force_obj_to_text(v) for k, v in obj.items() + } + elif isinstance(obj, (list, tuple)): + return type(obj)(force_obj_to_text(v) for v in obj) + else: + return obj diff --git a/pygeth/utils/filesystem.py b/pygeth/utils/filesystem.py new file mode 100644 index 00000000..98c7ad8a --- /dev/null +++ b/pygeth/utils/filesystem.py @@ -0,0 +1,59 @@ +import os +import sys +import shutil + + +if sys.version_info.major == 2: + FileNotFoundError = OSError + + +def ensure_path_exists(dir_path): + """ + Make sure that a path exists + """ + if not os.path.exists(dir_path): + os.mkdir(dir_path) + return True + return False + + +def remove_file_if_exists(path): + if os.path.isfile(path): + os.remove(path) + return True + return False + + +def remove_dir_if_exists(path): + if os.path.isdir(path): + shutil.rmtree(path) + return True + return False + + +def is_executable_available(program): + def is_exe(fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + fpath = os.path.dirname(program) + if fpath: + if is_exe(program): + return True + else: + for path in os.environ["PATH"].split(os.pathsep): + path = path.strip('"') + exe_file = os.path.join(path, program) + if is_exe(exe_file): + return True + + return False + + +def is_same_path(p1, p2): + n_p1 = os.path.abspath(os.path.expanduser(p1)) + n_p2 = os.path.abspath(os.path.expanduser(p2)) + + try: + return os.path.samefile(n_p1, n_p2) + except FileNotFoundError: + return n_p1 == n_p2 diff --git a/pygeth/utils/networking.py b/pygeth/utils/networking.py new file mode 100644 index 00000000..db475436 --- /dev/null +++ b/pygeth/utils/networking.py @@ -0,0 +1,20 @@ +import socket + + +def is_port_open(port): + sock = socket.socket() + try: + sock.bind(('127.0.0.1', port)) + except socket.error: + return False + else: + sock.close() + return True + + +def get_open_port(): + sock = socket.socket() + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + sock.close() + return str(port) diff --git a/pygeth/utils/proc.py b/pygeth/utils/proc.py new file mode 100644 index 00000000..d4ad111d --- /dev/null +++ b/pygeth/utils/proc.py @@ -0,0 +1,42 @@ +import time +import signal + + +def wait_for_popen(proc, max_wait=5): + wait_till = time.time() + 5 + while proc.poll() is None and time.time() < wait_till: + time.sleep(0.1) + + +def kill_proc(proc): + try: + if proc.poll() is None: + proc.send_signal(signal.SIGINT) + wait_for_popen(proc, 5) + if proc.poll() is None: + proc.terminate() + wait_for_popen(proc, 2) + if proc.poll() is None: + proc.kill() + wait_for_popen(proc, 1) + except KeyboardInterrupt: + proc.kill() + + +def format_error_message(prefix, command, return_code, stdoutdata, stderrdata): + lines = [prefix] + + lines.append("Command : {0}".format(' '.join(command))) + lines.append("Return Code: {0}".format(return_code)) + + if stdoutdata: + lines.append("stdout:\n`{0}`".format(stdoutdata)) + else: + lines.append("stdout: N/A") + + if stderrdata: + lines.append("stderr:\n`{0}`".format(stderrdata)) + else: + lines.append("stderr: N/A") + + return "\n".join(lines) diff --git a/pygeth/wrapper.py b/pygeth/wrapper.py new file mode 100644 index 00000000..0468c1ef --- /dev/null +++ b/pygeth/wrapper.py @@ -0,0 +1,159 @@ +import os +import functools +import tempfile + +from gevent import subprocess + +from .utils.networking import ( + is_port_open, + get_open_port, +) +from .utils.filesystem import ( + is_executable_available, +) + + +is_nice_available = functools.partial(is_executable_available, 'nice') + + +PYGETH_DIR = os.path.abspath(os.path.dirname(__file__)) + + +DEFAULT_PASSWORD_PATH = os.path.join(PYGETH_DIR, 'default_blockchain_password') +DEFAULT_GENESIS_PATH = os.path.join(PYGETH_DIR, 'genesis.json') + + +def construct_test_chain_kwargs(**overrides): + overrides.setdefault('genesis_path', DEFAULT_GENESIS_PATH) + overrides.setdefault('unlock', '0') + overrides.setdefault('password', DEFAULT_PASSWORD_PATH) + overrides.setdefault('mine', True) + overrides.setdefault('miner_threads', '1') + overrides.setdefault('no_discover', True) + overrides.setdefault('max_peers', '0') + overrides.setdefault('network_id', '1234') + + if is_port_open(30303): + overrides.setdefault('port', '30303') + else: + overrides.setdefault('port', get_open_port()) + + overrides.setdefault('rpc_enabled', True) + overrides.setdefault('rpc_addr', '127.0.0.1') + if is_port_open(8545): + overrides.setdefault('rpc_port', '8545') + else: + overrides.setdefault('rpc_port', get_open_port()) + + overrides.setdefault('ipc_path', tempfile.NamedTemporaryFile().name) + + overrides.setdefault('verbosity', '5') + return overrides + + +def construct_popen_command(data_dir=None, + geth_executable="geth", + genesis_path=None, + max_peers=None, + network_id=None, + no_discover=None, + mine=False, + miner_threads=None, + nice=True, + unlock=None, + password=None, + port=None, + verbosity=None, + ipc_path=None, + rpc_enabled=None, + rpc_addr=None, + rpc_port=None, + prefix_cmd=None, + suffix_args=None, + suffix_kwargs=None): + command = [] + + if nice and is_nice_available(): + command.extend(('nice', '-n', '20')) + + command.append(geth_executable) + + if rpc_enabled: + command.append('--rpc') + + if rpc_addr is not None: + command.extend(('--rpcaddr', rpc_addr)) + + if rpc_port is not None: + command.extend(('--rpcport', rpc_port)) + + if genesis_path is not None: + command.extend(('--genesis', genesis_path)) + + if data_dir is not None: + command.extend(('--datadir', data_dir)) + + if max_peers is not None: + command.extend(('--maxpeers', max_peers)) + + if network_id is not None: + command.extend(('--networkid', network_id)) + + if port is not None: + command.extend(('--port', port)) + + if ipc_path is not None: + command.extend(('--ipcpath', ipc_path)) + + if verbosity is not None: + command.extend(( + '--verbosity', verbosity, + )) + + if unlock is not None: + command.extend(( + '--unlock', unlock, + )) + + if password is not None: + command.extend(( + '--password', password, + )) + + if no_discover: + command.append('--nodiscover') + + if mine: + if unlock is None: + raise ValueError("Cannot mine without an unlocked account") + command.append('--mine') + + if miner_threads is not None: + if not mine: + raise ValueError("`mine` must be truthy when specifying `miner_threads`") + command.extend(('--minerthreads', miner_threads)) + + if suffix_kwargs: + command.extend(suffix_kwargs) + + if suffix_args: + command.extend(suffix_args) + + return command + + +def spawn_geth(geth_kwargs, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE): + command = construct_popen_command(**geth_kwargs) + + proc = subprocess.Popen( + command, + stdin=stdin, + stdout=stdout, + stderr=stderr, + bufsize=1, + ) + + return command, proc diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..baf4d951 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=2.9.2 +tox>=2.3.1 +requests==2.10.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..931b6196 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +gevent>=1.1.1 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..736aa5d4 --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import os + +from setuptools import ( + setup, + find_packages, +) + + +DIR = os.path.dirname(os.path.abspath(__file__)) + +readme = open(os.path.join(DIR, 'README.md')).read() + + +setup( + name='py-geth', + version="0.1.0", + description="""Run Go-Ethereum as a subprocess""", + long_description=readme, + author='Piper Merriam', + author_email='pipermerriam@gmail.com', + url='https://github.com/pipermerriam/py-geth', + include_package_data=True, + py_modules=['pygeth'], + install_requires=[ + "gevent>=1.1.1", + ], + license="MIT", + zip_safe=False, + keywords='ethereum go-ethereum geth', + packages=find_packages(exclude=["tests", "tests.*"]), + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + ], +) diff --git a/tests/accounts/conftest.py b/tests/accounts/conftest.py new file mode 100644 index 00000000..20c5fdb3 --- /dev/null +++ b/tests/accounts/conftest.py @@ -0,0 +1,24 @@ +import os + +import pytest + + +PROJECTS_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'projects') + + +@pytest.fixture +def one_account_data_dir(): + data_dir = os.path.join(PROJECTS_DIR, 'test-01') + return data_dir + + +@pytest.fixture +def three_account_data_dir(): + data_dir = os.path.join(PROJECTS_DIR, 'test-02') + return data_dir + + +@pytest.fixture +def no_account_data_dir(): + data_dir = os.path.join(PROJECTS_DIR, 'test-03') + return data_dir diff --git a/tests/accounts/projects/test-01/keystore/UTC--2015-08-24T21-30-14.222885490Z--ae71658b3ab452f7e4f03bda6f777b860b2e2ff2 b/tests/accounts/projects/test-01/keystore/UTC--2015-08-24T21-30-14.222885490Z--ae71658b3ab452f7e4f03bda6f777b860b2e2ff2 new file mode 100644 index 00000000..1ef325b9 --- /dev/null +++ b/tests/accounts/projects/test-01/keystore/UTC--2015-08-24T21-30-14.222885490Z--ae71658b3ab452f7e4f03bda6f777b860b2e2ff2 @@ -0,0 +1 @@ +{"address":"ae71658b3ab452f7e4f03bda6f777b860b2e2ff2","Crypto":{"cipher":"aes-128-ctr","ciphertext":"dcc6f842d72fdbb8afffc15ded1af0d74e613ba4d987d3cc83f6199da5761d1d","cipherparams":{"iv":"8a242df8817f6a89de25fa2c6f0540fd"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":262144,"p":1,"r":8,"salt":"d29b0b98e506d976e71c5487558b643461f5711d60e91686e5812be2cbdbdbcd"},"mac":"a0f724700dee043c55f36305005afbbcc9f3088ee1b7cdbed2dfcf28a0dba663"},"id":"29090707-ae9e-463b-8a16-870a5362f6d2","version":3} \ No newline at end of file diff --git a/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-30-14.222885490Z--ae71658b3ab452f7e4f03bda6f777b860b2e2ff2 b/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-30-14.222885490Z--ae71658b3ab452f7e4f03bda6f777b860b2e2ff2 new file mode 100644 index 00000000..1ef325b9 --- /dev/null +++ b/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-30-14.222885490Z--ae71658b3ab452f7e4f03bda6f777b860b2e2ff2 @@ -0,0 +1 @@ +{"address":"ae71658b3ab452f7e4f03bda6f777b860b2e2ff2","Crypto":{"cipher":"aes-128-ctr","ciphertext":"dcc6f842d72fdbb8afffc15ded1af0d74e613ba4d987d3cc83f6199da5761d1d","cipherparams":{"iv":"8a242df8817f6a89de25fa2c6f0540fd"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":262144,"p":1,"r":8,"salt":"d29b0b98e506d976e71c5487558b643461f5711d60e91686e5812be2cbdbdbcd"},"mac":"a0f724700dee043c55f36305005afbbcc9f3088ee1b7cdbed2dfcf28a0dba663"},"id":"29090707-ae9e-463b-8a16-870a5362f6d2","version":3} \ No newline at end of file diff --git a/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-32-00.716418819Z--e8e085862a8d951dd78ec5ea784b3e22ee1ca9c6 b/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-32-00.716418819Z--e8e085862a8d951dd78ec5ea784b3e22ee1ca9c6 new file mode 100644 index 00000000..02c6aad6 --- /dev/null +++ b/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-32-00.716418819Z--e8e085862a8d951dd78ec5ea784b3e22ee1ca9c6 @@ -0,0 +1 @@ +{"address":"e8e085862a8d951dd78ec5ea784b3e22ee1ca9c6","Crypto":{"cipher":"aes-128-ctr","ciphertext":"92ebde5b7f92b0cdbfcba1340a88a550d07a304f7ce333a901142a5c08258822","cipherparams":{"iv":"b0710ec019b1f96cfc21f4b8133e0be1"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":262144,"p":1,"r":8,"salt":"9ba6f3c6462054a25ef358f217a6c6aa2a1369c0dcef43112fa499fe72869028"},"mac":"7ed8591ce7f97783dd897c0c484f4c7e4905165d8695e60d6eac6ca65f26a475"},"id":"3985e8a9-9114-473f-aa1f-cbf0b768c510","version":3} \ No newline at end of file diff --git a/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-32-04.748321142Z--0da70f43a568e88168436be52ed129f4a9bbdaf5 b/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-32-04.748321142Z--0da70f43a568e88168436be52ed129f4a9bbdaf5 new file mode 100644 index 00000000..a3cfe7c6 --- /dev/null +++ b/tests/accounts/projects/test-02/keystore/UTC--2015-08-24T21-32-04.748321142Z--0da70f43a568e88168436be52ed129f4a9bbdaf5 @@ -0,0 +1 @@ +{"address":"0da70f43a568e88168436be52ed129f4a9bbdaf5","Crypto":{"cipher":"aes-128-ctr","ciphertext":"f8cb976d758fb6ba68068771394027d34da12adfdd8ed0ecd92b36ec7e30458a","cipherparams":{"iv":"6de38f095db7f3c73dd4b091aab5513b"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":262144,"p":1,"r":8,"salt":"2593f0013f0abe1e7a4bf861a33f42a9e00ea34c2be03466565a6ef7e751de83"},"mac":"20ab16c4dcaea6194429a575d500edb05ff55ad57809e0a6a117f632bb614a01"},"id":"080519cb-696a-4a73-893d-d825dd8b9332","version":3} \ No newline at end of file diff --git a/tests/accounts/test_account_list_parsing.py b/tests/accounts/test_account_list_parsing.py new file mode 100644 index 00000000..fbcde1fa --- /dev/null +++ b/tests/accounts/test_account_list_parsing.py @@ -0,0 +1,10 @@ +from pygeth.accounts import parse_geth_accounts + + +raw_accounts = b"""Account #0: {d3cda913deb6f67967b99d67acdfa1712c293601} +Account #1: {6f137a71a6f197df2cbbf010dcbd3c444ef5c925}\n""" +accounts = (b"0xd3cda913deb6f67967b99d67acdfa1712c293601", b"0x6f137a71a6f197df2cbbf010dcbd3c444ef5c925") + + +def test_parsing_accounts_output(): + assert parse_geth_accounts(raw_accounts) == accounts diff --git a/tests/accounts/test_create_geth_account.py b/tests/accounts/test_create_geth_account.py new file mode 100644 index 00000000..441d8d38 --- /dev/null +++ b/tests/accounts/test_create_geth_account.py @@ -0,0 +1,41 @@ +import os +import shutil + +import pytest + +from pygeth.chain import ( + get_chain_data_dir, +) +from pygeth.accounts import ( + create_new_account, + get_accounts, +) + + +def test_create_new_account_with_text_password(tmpdir): + data_dir = str(tmpdir.mkdir("data-dir")) + + assert not get_accounts(data_dir) + + account_0 = create_new_account(data_dir, b'some-text-password') + account_1 = create_new_account(data_dir, b'some-text-password') + + accounts = get_accounts(data_dir) + assert (account_0, account_1) == accounts + + +def test_create_new_account_with_file_based_password(tmpdir): + pw_file_path = str(tmpdir.mkdir("data-dir").join('geth_password_file')) + + with open(pw_file_path, 'w') as pw_file: + pw_file.write("some-text-password-in-a-file") + + data_dir = os.path.dirname(pw_file_path) + + assert not get_accounts(data_dir) + + account_0 = create_new_account(data_dir, pw_file_path) + account_1 = create_new_account(data_dir, pw_file_path) + + accounts = get_accounts(data_dir) + assert (account_0, account_1) == accounts diff --git a/tests/accounts/test_geth_accounts.py b/tests/accounts/test_geth_accounts.py new file mode 100644 index 00000000..bef34d26 --- /dev/null +++ b/tests/accounts/test_geth_accounts.py @@ -0,0 +1,25 @@ +from pygeth.accounts import ( + get_accounts, +) + + +def test_single_account(one_account_data_dir): + data_dir = one_account_data_dir + accounts = get_accounts(data_dir=data_dir) + assert accounts == (b'0xae71658b3ab452f7e4f03bda6f777b860b2e2ff2',) + + +def test_multiple_accounts(three_account_data_dir): + data_dir = three_account_data_dir + accounts = get_accounts(data_dir=data_dir) + assert accounts == ( + b'0xae71658b3ab452f7e4f03bda6f777b860b2e2ff2', + b'0xe8e085862a8d951dd78ec5ea784b3e22ee1ca9c6', + b'0x0da70f43a568e88168436be52ed129f4a9bbdaf5', + ) + + +def test_no_accounts(no_account_data_dir): + data_dir = no_account_data_dir + accounts = get_accounts(data_dir=data_dir) + assert accounts == tuple() diff --git a/tests/running/test_running_dev_chain.py b/tests/running/test_running_dev_chain.py new file mode 100644 index 00000000..5e9d0d49 --- /dev/null +++ b/tests/running/test_running_dev_chain.py @@ -0,0 +1,19 @@ +from pygeth.geth import DevGethProcess + + +def test_with_no_overrides(base_dir): + geth = DevGethProcess('testing', base_dir=base_dir) + + geth.start() + + assert geth.is_started + assert geth.is_alive + + geth.stop() + + assert geth.is_stopped + + +def test_dev_geth_process_generates_accounts(base_dir): + geth = DevGethProcess('testing', base_dir=base_dir) + assert len(geth.accounts) == 1 diff --git a/tests/utility/test_is_live_chain.py b/tests/utility/test_is_live_chain.py new file mode 100644 index 00000000..c1f2da57 --- /dev/null +++ b/tests/utility/test_is_live_chain.py @@ -0,0 +1,26 @@ +import pytest +import os + +from pygeth.chain import is_live_chain + + +@pytest.mark.parametrize( + 'platform,data_dir,should_be_live', + ( + ("darwin", "~", False), + ("darwin", "~/Library/Ethereum", True), + ("linux2", "~", False), + ("linux2", "~/.ethereum", True), + ), +) +def test_is_live_chain(monkeypatch, platform, data_dir, should_be_live): + monkeypatch.setattr('sys.platform', platform) + if platform == "win32": + monkeypatch.setattr('os.path.sep', '\\') + + expanded_data_dir = os.path.expanduser(data_dir) + relative_data_dir = os.path.relpath(expanded_data_dir) + + assert is_live_chain(data_dir) is should_be_live + assert is_live_chain(expanded_data_dir) is should_be_live + assert is_live_chain(relative_data_dir) is should_be_live diff --git a/tests/utility/test_is_testnet_chain.py b/tests/utility/test_is_testnet_chain.py new file mode 100644 index 00000000..6eb2a107 --- /dev/null +++ b/tests/utility/test_is_testnet_chain.py @@ -0,0 +1,27 @@ +import pytest +import os + +from pygeth.chain import is_testnet_chain + + +@pytest.mark.parametrize( + 'platform,data_dir,should_be_testnet', + ( + ("darwin", "~", False), + ("darwin", "~/Library/Ethereum/testnet", True), + ("linux2", "~", False), + ("linux2", "~/.ethereum/testnet", True), + ), +) +def test_is_testnet_chain(monkeypatch, platform, data_dir, should_be_testnet): + monkeypatch.setattr('sys.platform', platform) + if platform == "win32": + monkeypatch.setattr('os.path.sep', '\\') + + expanded_data_dir = os.path.expanduser(data_dir) + relative_data_dir = os.path.relpath(expanded_data_dir) + + assert is_testnet_chain(data_dir) is should_be_testnet + assert is_testnet_chain(expanded_data_dir) is should_be_testnet + assert is_testnet_chain(relative_data_dir) is should_be_testnet + diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..b7dcbdfd --- /dev/null +++ b/tox.ini @@ -0,0 +1,23 @@ +[tox] +envlist= + py27, + flake8 + +[flake8] +max-line-length= 100 +exclude= tests/* + +[testenv] +commands=py.test --tb native {posargs:tests} +deps = + https://github.com/pipermerriam/eth-testrpc/archive/v0.1.27.tar.gz + https://github.com/ethereum/ethash/archive/v23.1.tar.gz + -r{toxinidir}/requirements-dev.txt + +[testenv:py27] +basepython=python2.7 + +[testenv:flake8] +basepython=python +deps=flake8 +commands=flake8 {toxinidir}/populus