Skip to content

Commit

Permalink
feat: Windows support (#2)
Browse files Browse the repository at this point in the history
### What I did

Added Windows support for the Hardhat network provider.

### How I did it

See ApeWorX/ape#91 (comment) for steps
on installing Ape on Windows. Once I got that to work, all that was needed was
a small amount of OS-specific code to manage the Hardhat process on Windows.

### How to verify it

No CI support yet (blocked by ApeWorX/ape#91) but the
test suite is passing locally for me:

```
PS C:\Users\orayo\Documents\steve\ape-hardhat> ..\ape-env\Scripts\pytest.exe
=== test session starts ===
platform win32 -- Python 3.8.6, pytest-6.2.5, py-1.10.0, pluggy-0.13.1
rootdir: C:\Users\orayo\Documents\steve\ape-hardhat, configfile: pytest.ini
plugins: hypothesis-6.23.0, cov-2.12.1, forked-1.3.0, xdist-2.4.0, web3-5.23.1
gw0 [17] / gw1 [17] / gw2 [17] / gw3 [17]
................. [100%]
=== warnings summary ===
tests/test_hardhat.py::test_rpc_methods[get_balance-args1-0]
  c:\users\orayo\documents\steve\ape-env\lib\site-packages\web3\method.py:215: DeprecationWarning: getBalance is deprecated in favor of get_balance
    warnings.warn(

tests/test_hardhat.py::test_rpc_methods[get_nonce-args0-0]
  c:\users\orayo\documents\steve\ape-env\lib\site-packages\web3\method.py:215: DeprecationWarning: getTransactionCount is deprecated in favor of get_transaction_count
    warnings.warn(

tests/test_hardhat.py::test_rpc_methods[get_code-args2-]
  c:\users\orayo\documents\steve\ape-env\lib\site-packages\web3\method.py:215: DeprecationWarning: getCode is deprecated in favor of get_code
    warnings.warn(

-- Docs: https://docs.pytest.org/en/stable/warnings.html
=== 17 passed, 3 warnings in 61.00s (0:01:01) ===
```
  • Loading branch information
lost-theory authored Oct 2, 2021
1 parent 7f10b8d commit 769857b
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 22 deletions.
15 changes: 14 additions & 1 deletion ape_hardhat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

from ape import plugins

from .providers import HardhatNetworkConfig, HardhatProvider
from .providers import (
HardhatNetworkConfig,
HardhatProvider,
HardhatProviderError,
HardhatSubprocessError,
)


@plugins.register(plugins.Config)
Expand All @@ -16,3 +21,11 @@ def config_class():
@plugins.register(plugins.ProviderPlugin)
def providers():
yield "ethereum", "development", HardhatProvider


__all__ = [
"HardhatNetworkConfig",
"HardhatProvider",
"HardhatProviderError",
"HardhatSubprocessError",
]
95 changes: 76 additions & 19 deletions ape_hardhat/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import ctypes
import platform
import random
import shutil
import signal
import sys
import time
Expand All @@ -32,6 +33,7 @@
"""
HARDHAT_START_NETWORK_RETRIES = [0.1, 0.2, 0.3, 0.5, 1.0] # seconds between network retries
HARDHAT_START_PROCESS_ATTEMPTS = 3 # number of attempts to start subprocess before giving up
PROCESS_WAIT_TIMEOUT = 15 # seconds to wait for process to terminate


def _signal_handler(signum, frame):
Expand All @@ -40,17 +42,66 @@ def _signal_handler(signum, frame):
sys.exit(143 if signum == signal.SIGTERM else 130)


def _set_death_signal():
"""Automatically sends SIGTERM to child subprocesses when parent process dies."""
def _linux_set_death_signal():
"""
Automatically sends SIGTERM to child subprocesses when parent process
dies (only usable on Linux).
"""
# from: https://stackoverflow.com/a/43152455/75956
# the first argument, 1, is the flag for PR_SET_PDEATHSIG
# the second argument is what signal to send to child subprocesses
libc = ctypes.CDLL("libc.so.6")
return libc.prctl(1, signal.SIGTERM)


def _get_preexec_fn():
if platform.uname().system == "Linux":
# from: https://stackoverflow.com/a/43152455/75956
# the first argument "1" is PR_SET_PDEATHSIG
# the second argument is what signal to send the child
libc = ctypes.CDLL("libc.so.6")
return libc.prctl(1, signal.SIGTERM)
return _linux_set_death_signal
else:
return None


def _windows_taskkill(pid: int) -> None:
"""
Kills the given process and all child processes using taskkill.exe. Used
for subprocesses started up on Windows which run in a cmd.exe wrapper that
doesn't propagate signals by default (leaving orphaned processes).
"""
taskkill_bin = shutil.which("taskkill")
if not taskkill_bin:
raise HardhatSubprocessError("Could not find taskkill.exe executable.")
proc = Popen(
[
taskkill_bin,
"/F", # forcefully terminate
"/T", # terminate child processes
"/PID",
str(pid),
],
stderr=PIPE,
stdout=PIPE,
stdin=PIPE,
)
proc.wait(timeout=PROCESS_WAIT_TIMEOUT)


def _kill_process(proc):
"""Helper function for killing a process and its child subprocesses."""
if platform.uname().system == "Windows":
_windows_taskkill(proc.pid)
proc.kill()
proc.wait(timeout=PROCESS_WAIT_TIMEOUT)


class HardhatProviderError(ProviderError):
"""An error related to the Hardhat network provider plugin."""

pass


class HardhatSubprocessError(ProviderError):
class HardhatSubprocessError(HardhatProviderError):
"""An error related to launching subprocesses to run Hardhat."""

pass


Expand All @@ -70,17 +121,22 @@ def __post_init__(self):
)
self.port = None
self.process = None
self.npx_bin = shutil.which("npx")

hardhat_config_file = self.network.config_manager.PROJECT_FOLDER / "hardhat.config.js"

if not hardhat_config_file.is_file():
hardhat_config_file.write_text(HARDHAT_CONFIG)

if call(["npx", "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0:
if not self.npx_bin:
raise HardhatSubprocessError(
"Could not locate NPM executable. See ape-hardhat README for install steps."
)
if call([self.npx_bin, "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0:
raise HardhatSubprocessError(
"Missing npx binary. See ape-hardhat README for install steps."
"NPM executable returned error code. See ape-hardhat README for install steps."
)
if call(["npx", "hardhat", "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0:
if call([self.npx_bin, "hardhat", "--version"], stderr=PIPE, stdout=PIPE, stdin=PIPE) != 0:
raise HardhatSubprocessError(
"Missing hardhat NPM package. See ape-hardhat README for install steps."
)
Expand All @@ -95,8 +151,7 @@ def __post_init__(self):

def _start_process(self):
"""Start the hardhat process and wait for it to respond over the network."""
# handle configs
cmd = ["npx", "hardhat", "node"]
cmd = [self.npx_bin, "hardhat", "node"]

# pick a random port if one isn't configured
self.port = self.config.port
Expand All @@ -105,7 +160,7 @@ def _start_process(self):
cmd.extend(["--port", str(self.port)])

# TODO: Add configs to send stdout to logger / redirect to a file in plugin data dir?
process = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, preexec_fn=_set_death_signal)
process = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, preexec_fn=_get_preexec_fn())
connected = False
for retry_time in self.config.network_retries:
time.sleep(retry_time)
Expand All @@ -130,12 +185,12 @@ def _start_process(self):
)
self.process = process

def _verify_connection(self):
def _verify_connection(self) -> bool:
"""Make a network call for chain_id and verify the result."""
try:
chain_id = self.chain_id
if chain_id != HARDHAT_CHAIN_ID:
raise AssertionError(f"Unexpected chain ID: {chain_id}")
raise HardhatProviderError(f"Unexpected chain ID: {chain_id}")
return True
except Exception as exc:
print("Hardhat connection failed:", exc)
Expand All @@ -145,7 +200,9 @@ def connect(self):
"""Start the hardhat process and verify it's up and accepting connections."""

if self.process:
raise RuntimeError("Cannot connect twice. Call disconnect before connecting again.")
raise HardhatProviderError(
"Cannot connect twice. Call disconnect before connecting again."
)

if self.config.port:
# if a port is configured, only make one start up attempt
Expand Down Expand Up @@ -177,7 +234,7 @@ def uri(self) -> str:
# number, so let's build the URI using that port number
return f"http://localhost:{self.port}"
else:
raise RuntimeError("Can't build URI before `connect` is called.")
raise HardhatProviderError("Can't build URI before `connect` is called.")

@property # type: ignore
def _web3(self):
Expand All @@ -197,7 +254,7 @@ def _web3(self, value):
def disconnect(self):
super().disconnect()
if self.process:
self.process.kill()
_kill_process(self.process)
self.process = None
self.port = None

Expand Down
4 changes: 2 additions & 2 deletions tests/test_hardhat.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from conftest import get_network_config
from hexbytes import HexBytes

from ape_hardhat import HardhatProvider
from ape_hardhat import HardhatProvider, HardhatProviderError

TEST_WALLET_ADDRESS = "0xD9b7fdb3FC0A0Aa3A507dCf0976bc23D49a9C7A3"
TEST_CUSTOM_PORT = 8555 # vs. Hardhat's default of 8545
Expand Down Expand Up @@ -139,5 +139,5 @@ def test_unlock_account(hardhat_provider):

def test_double_connect(hardhat_provider):
# connect has already been called once as part of the fixture, so connecting again should fail
with pytest.raises(RuntimeError):
with pytest.raises(HardhatProviderError):
hardhat_provider.connect()

0 comments on commit 769857b

Please sign in to comment.