brawny 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Network configuration loading (Brownie-compatible).
|
|
2
|
+
|
|
3
|
+
Loads from:
|
|
4
|
+
1. ~/.brawny/network-config.yaml (if exists)
|
|
5
|
+
2. Auto-copies ~/.brownie/network-config.yaml to ~/.brawny/ on first use
|
|
6
|
+
3. Built-in defaults
|
|
7
|
+
|
|
8
|
+
NOTE: This module provides Brownie-compatible ~/.brawny/network-config.yaml support.
|
|
9
|
+
Project-level config.yaml network sections are no longer supported.
|
|
10
|
+
They are in separate namespaces and serve different purposes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Pattern to match ${VAR}, ${VAR:-default}, or $VAR forms (consistent with brawny.config)
|
|
26
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}?")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EnvVarExpansionError(ValueError):
|
|
30
|
+
"""Raised when an environment variable cannot be expanded."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, var_name: str, original_value: str, network_id: str | None = None):
|
|
33
|
+
self.var_name = var_name
|
|
34
|
+
self.original_value = original_value
|
|
35
|
+
self.network_id = network_id
|
|
36
|
+
network_ctx = f" in network '{network_id}'" if network_id else ""
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"Environment variable '{var_name}' is not set{network_ctx}. "
|
|
39
|
+
f"Original value: {original_value}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _expand_env_var(value: str, *, network_id: str | None = None) -> str | None:
|
|
44
|
+
"""Expand environment variables in a string.
|
|
45
|
+
|
|
46
|
+
Supports:
|
|
47
|
+
- $VAR or ${VAR} - replaced with env var value
|
|
48
|
+
- ${VAR:-default} - replaced with env var value, or default if not set
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
- None if value is purely an env var that wasn't set (allows filtering)
|
|
52
|
+
- Expanded string otherwise
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
- EnvVarExpansionError if a var inside a larger string is unset (partial expansion)
|
|
56
|
+
e.g., "https://alchemy.com/v2/$KEY" with KEY unset is an error, not a silent "/v2/"
|
|
57
|
+
"""
|
|
58
|
+
unset_vars: list[str] = []
|
|
59
|
+
|
|
60
|
+
def replacer(match: re.Match[str]) -> str:
|
|
61
|
+
var_name = match.group(1)
|
|
62
|
+
default_val = match.group(2) # None if no default specified
|
|
63
|
+
env_val = os.environ.get(var_name)
|
|
64
|
+
if env_val is not None:
|
|
65
|
+
return env_val
|
|
66
|
+
if default_val is not None:
|
|
67
|
+
return default_val
|
|
68
|
+
# Track unset vars without defaults
|
|
69
|
+
unset_vars.append(var_name)
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
result = _ENV_VAR_PATTERN.sub(replacer, value)
|
|
73
|
+
|
|
74
|
+
# If the ENTIRE string was a single env var that wasn't set → return None (filterable)
|
|
75
|
+
if result == "" and len(unset_vars) == 1 and value.strip() in (f"${unset_vars[0]}", f"${{{unset_vars[0]}}}"):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# If ANY env var was unset inside a larger string → error (partial expansion is dangerous)
|
|
79
|
+
if unset_vars:
|
|
80
|
+
raise EnvVarExpansionError(unset_vars[0], value, network_id)
|
|
81
|
+
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class NetworkConfig:
|
|
87
|
+
"""Configuration for a single network (Brownie-compatible format)."""
|
|
88
|
+
|
|
89
|
+
id: str
|
|
90
|
+
hosts: list[str] # Flexible: accepts string or list in config, stored as list
|
|
91
|
+
chainid: int | None = None
|
|
92
|
+
name: str | None = None
|
|
93
|
+
explorer: str | None = None
|
|
94
|
+
multicall2: str | None = None # Passed to Contract layer for batch calls
|
|
95
|
+
timeout: int = 30
|
|
96
|
+
|
|
97
|
+
# RPC settings (passed to RPCManager for production-grade handling)
|
|
98
|
+
max_retries: int = 3
|
|
99
|
+
retry_backoff_base: float = 1.0
|
|
100
|
+
circuit_breaker_seconds: int = 300
|
|
101
|
+
rate_limit_per_second: float | None = None
|
|
102
|
+
rate_limit_burst: int | None = None
|
|
103
|
+
|
|
104
|
+
# Development network fields (None for live networks)
|
|
105
|
+
cmd: str | None = None
|
|
106
|
+
cmd_settings: dict[str, Any] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_development(self) -> bool:
|
|
110
|
+
return self.cmd is not None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_fork(self) -> bool:
|
|
114
|
+
return self.cmd_settings.get("fork") is not None
|
|
115
|
+
|
|
116
|
+
def get_endpoints(self) -> list[str]:
|
|
117
|
+
"""Get RPC endpoints with env var expansion.
|
|
118
|
+
|
|
119
|
+
- Pure env var hosts (e.g., "$BACKUP_RPC") are filtered if unset
|
|
120
|
+
- Partial expansion (e.g., "https://alchemy.com/v2/$KEY" with KEY unset) raises error
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
EnvVarExpansionError: If a partial env var expansion would create invalid URL
|
|
124
|
+
ValueError: If no valid endpoints remain after expansion
|
|
125
|
+
"""
|
|
126
|
+
endpoints = []
|
|
127
|
+
for host in self.hosts:
|
|
128
|
+
expanded = _expand_env_var(host, network_id=self.id)
|
|
129
|
+
if expanded: # Skip None (pure env var that wasn't set)
|
|
130
|
+
endpoints.append(expanded)
|
|
131
|
+
|
|
132
|
+
if not endpoints:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Network '{self.id}' has no valid RPC endpoints after env var expansion. "
|
|
135
|
+
f"Original hosts: {self.hosts}"
|
|
136
|
+
)
|
|
137
|
+
return endpoints
|
|
138
|
+
|
|
139
|
+
def get_host_with_port(self) -> str:
|
|
140
|
+
"""Get first host URL with port for dev networks.
|
|
141
|
+
|
|
142
|
+
Uses urllib.parse to correctly detect if port is already in URL.
|
|
143
|
+
e.g., "http://127.0.0.1" needs port appended, "http://127.0.0.1:8545" doesn't.
|
|
144
|
+
"""
|
|
145
|
+
from urllib.parse import urlparse
|
|
146
|
+
|
|
147
|
+
endpoints = self.get_endpoints()
|
|
148
|
+
host = endpoints[0]
|
|
149
|
+
|
|
150
|
+
# Only append port for dev networks without explicit port
|
|
151
|
+
if self.is_development:
|
|
152
|
+
parsed = urlparse(host)
|
|
153
|
+
if not parsed.port: # No explicit port in URL
|
|
154
|
+
port = self.cmd_settings.get("port", 8545)
|
|
155
|
+
# Reconstruct URL with port
|
|
156
|
+
host = f"{parsed.scheme}://{parsed.hostname}:{port}{parsed.path}"
|
|
157
|
+
|
|
158
|
+
return host
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_config_path() -> Path | None:
|
|
162
|
+
"""Get network config file path. Auto-copies brownie config on first use."""
|
|
163
|
+
brawny_config = Path.home() / ".brawny" / "network-config.yaml"
|
|
164
|
+
|
|
165
|
+
if brawny_config.exists():
|
|
166
|
+
return brawny_config
|
|
167
|
+
|
|
168
|
+
# Auto-copy from brownie on first use
|
|
169
|
+
brownie_config = Path.home() / ".brownie" / "network-config.yaml"
|
|
170
|
+
if brownie_config.exists():
|
|
171
|
+
brawny_config.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
shutil.copy(brownie_config, brawny_config)
|
|
173
|
+
return brawny_config
|
|
174
|
+
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_host(host_value: str | list | None, default: str = "") -> list[str]:
|
|
179
|
+
"""Parse host field - accepts string or list, returns list."""
|
|
180
|
+
if host_value is None:
|
|
181
|
+
return [default] if default else []
|
|
182
|
+
if isinstance(host_value, str):
|
|
183
|
+
return [host_value] if host_value else []
|
|
184
|
+
return list(host_value)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_networks(data: dict) -> dict[str, NetworkConfig]:
|
|
188
|
+
"""Parse network config file."""
|
|
189
|
+
networks: dict[str, NetworkConfig] = {}
|
|
190
|
+
|
|
191
|
+
# Parse live networks
|
|
192
|
+
for group in data.get("live", []):
|
|
193
|
+
for net in group.get("networks", []):
|
|
194
|
+
networks[net["id"]] = NetworkConfig(
|
|
195
|
+
id=net["id"],
|
|
196
|
+
hosts=_parse_host(net.get("host")),
|
|
197
|
+
chainid=int(net["chainid"]) if "chainid" in net else None,
|
|
198
|
+
name=net.get("name"),
|
|
199
|
+
explorer=net.get("explorer"),
|
|
200
|
+
multicall2=net.get("multicall2"),
|
|
201
|
+
timeout=net.get("timeout", 30),
|
|
202
|
+
max_retries=net.get("max_retries", 3),
|
|
203
|
+
retry_backoff_base=net.get("retry_backoff_base", 1.0),
|
|
204
|
+
circuit_breaker_seconds=net.get("circuit_breaker_seconds", 300),
|
|
205
|
+
rate_limit_per_second=net.get("rate_limit_per_second"),
|
|
206
|
+
rate_limit_burst=net.get("rate_limit_burst"),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Parse development networks (fork resolution happens at connect time)
|
|
210
|
+
for net in data.get("development", []):
|
|
211
|
+
networks[net["id"]] = NetworkConfig(
|
|
212
|
+
id=net["id"],
|
|
213
|
+
hosts=_parse_host(net.get("host"), default="http://127.0.0.1"),
|
|
214
|
+
name=net.get("name"),
|
|
215
|
+
timeout=net.get("timeout", 120),
|
|
216
|
+
cmd=net.get("cmd"),
|
|
217
|
+
cmd_settings=dict(net.get("cmd_settings", {})),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return networks
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_networks() -> dict[str, NetworkConfig]:
|
|
224
|
+
"""Load all network configurations from Brownie-style config."""
|
|
225
|
+
networks: dict[str, NetworkConfig] = {}
|
|
226
|
+
|
|
227
|
+
# Load from config file
|
|
228
|
+
config_path = _get_config_path()
|
|
229
|
+
if config_path:
|
|
230
|
+
try:
|
|
231
|
+
with open(config_path) as f:
|
|
232
|
+
data = yaml.safe_load(f) or {}
|
|
233
|
+
networks = _parse_networks(data)
|
|
234
|
+
except yaml.YAMLError as e:
|
|
235
|
+
raise ValueError(f"Invalid network config at {config_path}: {e}")
|
|
236
|
+
|
|
237
|
+
# Add built-in defaults (only if not already defined)
|
|
238
|
+
_add_defaults(networks)
|
|
239
|
+
|
|
240
|
+
return networks
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _add_defaults(networks: dict[str, NetworkConfig]) -> None:
|
|
244
|
+
"""Add built-in defaults if not already present."""
|
|
245
|
+
if "development" not in networks:
|
|
246
|
+
networks["development"] = NetworkConfig(
|
|
247
|
+
id="development",
|
|
248
|
+
name="Anvil (Local)",
|
|
249
|
+
hosts=["http://127.0.0.1"],
|
|
250
|
+
cmd="anvil",
|
|
251
|
+
cmd_settings={"port": 8545, "accounts": 10},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if "mainnet-fork" not in networks:
|
|
255
|
+
# Use env var for fork URL with public fallback
|
|
256
|
+
# Users should set BRAWNY_FORK_RPC for reliable/fast forking
|
|
257
|
+
fork_url = "${BRAWNY_FORK_RPC:-https://eth.llamarpc.com}"
|
|
258
|
+
networks["mainnet-fork"] = NetworkConfig(
|
|
259
|
+
id="mainnet-fork",
|
|
260
|
+
name="Anvil (Mainnet Fork)",
|
|
261
|
+
hosts=["http://127.0.0.1"],
|
|
262
|
+
cmd="anvil",
|
|
263
|
+
timeout=120,
|
|
264
|
+
cmd_settings={
|
|
265
|
+
"port": 8546,
|
|
266
|
+
"fork": fork_url, # Expanded at connect time
|
|
267
|
+
"accounts": 10,
|
|
268
|
+
},
|
|
269
|
+
)
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Network connection management.
|
|
2
|
+
|
|
3
|
+
Provides Brownie-compatible network.connect()/disconnect() API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import atexit
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from brawny.networks.config import NetworkConfig, load_networks
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from brawny._rpc import RPCManager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_pidfile_dir() -> Path:
|
|
26
|
+
"""Get directory for PID files."""
|
|
27
|
+
path = Path.home() / ".brawny" / "pids"
|
|
28
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NetworkManager:
|
|
33
|
+
"""Manages network connections with Brownie-compatible API.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
from brawny import network
|
|
37
|
+
|
|
38
|
+
network.connect("mainnet")
|
|
39
|
+
print(network.show_active()) # "mainnet"
|
|
40
|
+
print(network.chain_id) # 1
|
|
41
|
+
network.disconnect()
|
|
42
|
+
|
|
43
|
+
Dev network lifecycle:
|
|
44
|
+
- PID files stored in ~/.brawny/pids/{network_id}.json
|
|
45
|
+
- Processes cleaned up on disconnect() or atexit
|
|
46
|
+
- Handles already-dead processes gracefully
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._networks: dict[str, NetworkConfig] | None = None
|
|
51
|
+
self._active: NetworkConfig | None = None
|
|
52
|
+
self._rpc: RPCManager | None = None
|
|
53
|
+
self._rpc_process: subprocess.Popen | None = None
|
|
54
|
+
self._rpc_process_network_id: str | None = None # Track which network we started
|
|
55
|
+
self._chain_id: int | None = None # Cached after first lookup
|
|
56
|
+
|
|
57
|
+
# Register cleanup on exit (handles normal exit, not SIGKILL)
|
|
58
|
+
atexit.register(self._cleanup)
|
|
59
|
+
|
|
60
|
+
# Handle SIGTERM gracefully (e.g., from container orchestration)
|
|
61
|
+
if sys.platform != "win32":
|
|
62
|
+
signal.signal(signal.SIGTERM, self._signal_cleanup)
|
|
63
|
+
|
|
64
|
+
def _signal_cleanup(self, signum: int, frame: object) -> None:
|
|
65
|
+
"""Handle SIGTERM by cleaning up and re-raising."""
|
|
66
|
+
self._cleanup()
|
|
67
|
+
# Re-raise to allow normal signal handling
|
|
68
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
69
|
+
os.kill(os.getpid(), signum)
|
|
70
|
+
|
|
71
|
+
def _cleanup(self) -> None:
|
|
72
|
+
"""Kill RPC process on exit. Safe to call multiple times."""
|
|
73
|
+
if self._rpc_process is None:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# Check if still running (poll() returns None if running)
|
|
77
|
+
if self._rpc_process.poll() is None:
|
|
78
|
+
self._rpc_process.terminate()
|
|
79
|
+
try:
|
|
80
|
+
self._rpc_process.wait(timeout=3)
|
|
81
|
+
except subprocess.TimeoutExpired:
|
|
82
|
+
self._rpc_process.kill()
|
|
83
|
+
self._rpc_process.wait(timeout=1)
|
|
84
|
+
|
|
85
|
+
# Clean up PID file
|
|
86
|
+
if self._rpc_process_network_id:
|
|
87
|
+
self._remove_pidfile(self._rpc_process_network_id)
|
|
88
|
+
self._rpc_process_network_id = None
|
|
89
|
+
|
|
90
|
+
self._rpc_process = None
|
|
91
|
+
|
|
92
|
+
def _write_pidfile(self, network_id: str, pid: int, port: int) -> None:
|
|
93
|
+
"""Write PID file for debugging."""
|
|
94
|
+
pidfile = _get_pidfile_dir() / f"{network_id}.json"
|
|
95
|
+
pidfile.write_text(json.dumps({"pid": pid, "port": port, "network_id": network_id}))
|
|
96
|
+
|
|
97
|
+
def _remove_pidfile(self, network_id: str) -> None:
|
|
98
|
+
"""Remove PID file."""
|
|
99
|
+
pidfile = _get_pidfile_dir() / f"{network_id}.json"
|
|
100
|
+
pidfile.unlink(missing_ok=True)
|
|
101
|
+
|
|
102
|
+
def _ensure_networks(self) -> dict[str, NetworkConfig]:
|
|
103
|
+
"""Lazily load networks."""
|
|
104
|
+
if self._networks is None:
|
|
105
|
+
self._networks = load_networks()
|
|
106
|
+
return self._networks
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_connected(self) -> bool:
|
|
110
|
+
"""Check if connected to a network."""
|
|
111
|
+
return self._active is not None and self._rpc is not None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def chain_id(self) -> int | None:
|
|
115
|
+
"""Get current chain ID (cached)."""
|
|
116
|
+
if not self.is_connected or self._rpc is None:
|
|
117
|
+
return None
|
|
118
|
+
if self._chain_id is None:
|
|
119
|
+
self._chain_id = self._rpc.get_chain_id()
|
|
120
|
+
return self._chain_id
|
|
121
|
+
|
|
122
|
+
def show_active(self) -> str | None:
|
|
123
|
+
"""Get ID of currently active network."""
|
|
124
|
+
return self._active.id if self._active else None
|
|
125
|
+
|
|
126
|
+
def connect(
|
|
127
|
+
self,
|
|
128
|
+
network_id: str | None = None,
|
|
129
|
+
launch_rpc: bool = True,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Connect to a network.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
network_id: Network ID to connect to. Priority:
|
|
135
|
+
1. Explicit argument
|
|
136
|
+
2. BRAWNY_NETWORK env var
|
|
137
|
+
3. "development" default
|
|
138
|
+
launch_rpc: If True, launch RPC process for development networks
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ConnectionError: If already connected
|
|
142
|
+
KeyError: If network_id not found
|
|
143
|
+
"""
|
|
144
|
+
if self.is_connected:
|
|
145
|
+
raise ConnectionError(
|
|
146
|
+
f"Already connected to '{self._active.id}'. "
|
|
147
|
+
"Call network.disconnect() first."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
networks = self._ensure_networks()
|
|
151
|
+
|
|
152
|
+
# Priority: explicit arg > env var > default
|
|
153
|
+
network_id = network_id or os.environ.get("BRAWNY_NETWORK") or "development"
|
|
154
|
+
|
|
155
|
+
if network_id not in networks:
|
|
156
|
+
available = ", ".join(sorted(networks.keys()))
|
|
157
|
+
raise KeyError(f"Network '{network_id}' not found. Available: {available}")
|
|
158
|
+
|
|
159
|
+
config = networks[network_id]
|
|
160
|
+
|
|
161
|
+
# Resolve fork reference at connect time
|
|
162
|
+
self._resolve_fork(config, networks)
|
|
163
|
+
|
|
164
|
+
# Get endpoints (list) - RPCManager handles failover automatically
|
|
165
|
+
endpoints = config.get_endpoints()
|
|
166
|
+
|
|
167
|
+
# Launch RPC for development networks
|
|
168
|
+
if config.is_development and launch_rpc:
|
|
169
|
+
local_url = config.get_host_with_port()
|
|
170
|
+
try:
|
|
171
|
+
self._launch_rpc(config)
|
|
172
|
+
self._wait_for_rpc(local_url, timeout=config.timeout)
|
|
173
|
+
except Exception:
|
|
174
|
+
# Clean up on failure
|
|
175
|
+
if self._rpc_process:
|
|
176
|
+
self._rpc_process.terminate()
|
|
177
|
+
self._rpc_process = None
|
|
178
|
+
raise
|
|
179
|
+
# Dev networks only use local endpoint
|
|
180
|
+
endpoints = [local_url]
|
|
181
|
+
|
|
182
|
+
# Create RPCManager with full configuration (preserves brawny's advantages)
|
|
183
|
+
from brawny._rpc import RPCManager
|
|
184
|
+
|
|
185
|
+
self._rpc = RPCManager(
|
|
186
|
+
endpoints=endpoints,
|
|
187
|
+
timeout_seconds=float(config.timeout),
|
|
188
|
+
max_retries=config.max_retries,
|
|
189
|
+
retry_backoff_base=config.retry_backoff_base,
|
|
190
|
+
circuit_breaker_seconds=config.circuit_breaker_seconds,
|
|
191
|
+
rate_limit_per_second=config.rate_limit_per_second,
|
|
192
|
+
rate_limit_burst=config.rate_limit_burst,
|
|
193
|
+
chain_id=config.chainid,
|
|
194
|
+
)
|
|
195
|
+
self._active = config
|
|
196
|
+
self._chain_id = None # Reset cache
|
|
197
|
+
|
|
198
|
+
# Verify connection and cache chain_id
|
|
199
|
+
try:
|
|
200
|
+
self._chain_id = self._rpc.get_chain_id()
|
|
201
|
+
except Exception as e:
|
|
202
|
+
self._cleanup_on_failure()
|
|
203
|
+
raise ConnectionError(f"Failed to connect to {network_id}: {e}")
|
|
204
|
+
|
|
205
|
+
# Update config chainid if not set
|
|
206
|
+
if config.chainid is None:
|
|
207
|
+
config.chainid = self._chain_id
|
|
208
|
+
|
|
209
|
+
def _cleanup_on_failure(self) -> None:
|
|
210
|
+
"""Clean up state after connection failure."""
|
|
211
|
+
if self._rpc_process:
|
|
212
|
+
self._rpc_process.terminate()
|
|
213
|
+
self._rpc_process = None
|
|
214
|
+
self._rpc = None
|
|
215
|
+
self._active = None
|
|
216
|
+
self._chain_id = None
|
|
217
|
+
|
|
218
|
+
def _resolve_fork(
|
|
219
|
+
self,
|
|
220
|
+
config: NetworkConfig,
|
|
221
|
+
networks: dict[str, NetworkConfig],
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Resolve fork reference to actual URL at connect time.
|
|
224
|
+
|
|
225
|
+
Only inherits chain_id/explorer/multicall2 from forked network if not
|
|
226
|
+
explicitly set on the dev config. This allows intentional overrides
|
|
227
|
+
(e.g., testing with a different chain_id).
|
|
228
|
+
"""
|
|
229
|
+
fork_ref = config.cmd_settings.get("fork")
|
|
230
|
+
if not fork_ref:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
# If fork is a network ID, resolve to URL (uses first endpoint from list)
|
|
234
|
+
if fork_ref in networks:
|
|
235
|
+
live = networks[fork_ref]
|
|
236
|
+
endpoints = live.get_endpoints()
|
|
237
|
+
config.cmd_settings["fork"] = endpoints[0] if endpoints else fork_ref
|
|
238
|
+
|
|
239
|
+
# Only inherit properties if not explicitly set on dev config
|
|
240
|
+
if config.chainid is None:
|
|
241
|
+
config.chainid = live.chainid
|
|
242
|
+
if config.explorer is None:
|
|
243
|
+
config.explorer = live.explorer
|
|
244
|
+
if config.multicall2 is None:
|
|
245
|
+
config.multicall2 = live.multicall2
|
|
246
|
+
|
|
247
|
+
# Set chain_id in cmd_settings for Anvil (only if not already set)
|
|
248
|
+
if "chain_id" not in config.cmd_settings and config.chainid:
|
|
249
|
+
config.cmd_settings["chain_id"] = config.chainid
|
|
250
|
+
|
|
251
|
+
def disconnect(self, kill_rpc: bool = True) -> None:
|
|
252
|
+
"""Disconnect from the current network.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
kill_rpc: If True (default), terminate any RPC process we started.
|
|
256
|
+
Safe to call even if process already died.
|
|
257
|
+
"""
|
|
258
|
+
if not self._active:
|
|
259
|
+
raise ConnectionError("Not connected to any network")
|
|
260
|
+
|
|
261
|
+
if kill_rpc and self._rpc_process:
|
|
262
|
+
# Safe termination - handles already-dead processes
|
|
263
|
+
if self._rpc_process.poll() is None:
|
|
264
|
+
self._rpc_process.terminate()
|
|
265
|
+
try:
|
|
266
|
+
self._rpc_process.wait(timeout=3)
|
|
267
|
+
except subprocess.TimeoutExpired:
|
|
268
|
+
self._rpc_process.kill()
|
|
269
|
+
self._rpc_process.wait(timeout=1)
|
|
270
|
+
|
|
271
|
+
# Clean up PID file
|
|
272
|
+
if self._rpc_process_network_id:
|
|
273
|
+
self._remove_pidfile(self._rpc_process_network_id)
|
|
274
|
+
self._rpc_process_network_id = None
|
|
275
|
+
|
|
276
|
+
self._rpc_process = None
|
|
277
|
+
|
|
278
|
+
self._rpc = None
|
|
279
|
+
self._active = None
|
|
280
|
+
self._chain_id = None
|
|
281
|
+
|
|
282
|
+
def _launch_rpc(self, config: NetworkConfig) -> None:
|
|
283
|
+
"""Launch RPC process for development network.
|
|
284
|
+
|
|
285
|
+
Writes PID file to ~/.brawny/pids/{network_id}.json for debugging.
|
|
286
|
+
"""
|
|
287
|
+
import socket
|
|
288
|
+
|
|
289
|
+
if config.cmd is None:
|
|
290
|
+
raise RuntimeError(f"Network '{config.id}' has no cmd configured")
|
|
291
|
+
|
|
292
|
+
# Check if port is available
|
|
293
|
+
port = config.cmd_settings.get("port", 8545)
|
|
294
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
295
|
+
if s.connect_ex(("127.0.0.1", port)) == 0:
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
f"Port {port} already in use. "
|
|
298
|
+
f"Use a different port in cmd_settings or stop the existing process."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Check if command exists
|
|
302
|
+
cmd_name = config.cmd.split()[0]
|
|
303
|
+
if not shutil.which(cmd_name):
|
|
304
|
+
raise RuntimeError(f"RPC command '{cmd_name}' not found. Install it first.")
|
|
305
|
+
|
|
306
|
+
# Build command as list (avoids shell=True security issues)
|
|
307
|
+
cmd_parts = self._build_rpc_command(config)
|
|
308
|
+
|
|
309
|
+
# Platform-specific process group handling
|
|
310
|
+
kwargs: dict[str, object] = {
|
|
311
|
+
"stdout": subprocess.DEVNULL,
|
|
312
|
+
"stderr": subprocess.DEVNULL,
|
|
313
|
+
}
|
|
314
|
+
if sys.platform != "win32":
|
|
315
|
+
kwargs["start_new_session"] = True # Unix: create new process group
|
|
316
|
+
|
|
317
|
+
self._rpc_process = subprocess.Popen(cmd_parts, **kwargs)
|
|
318
|
+
self._rpc_process_network_id = config.id
|
|
319
|
+
|
|
320
|
+
# Write PID file for debugging
|
|
321
|
+
self._write_pidfile(config.id, self._rpc_process.pid, port)
|
|
322
|
+
|
|
323
|
+
def _build_rpc_command(self, config: NetworkConfig) -> list[str]:
|
|
324
|
+
"""Build command list for RPC process."""
|
|
325
|
+
if config.cmd is None:
|
|
326
|
+
raise RuntimeError(f"Network '{config.id}' has no cmd configured")
|
|
327
|
+
|
|
328
|
+
# Split base command (handles "npx hardhat node" etc.)
|
|
329
|
+
cmd_parts = config.cmd.split()
|
|
330
|
+
settings = config.cmd_settings
|
|
331
|
+
|
|
332
|
+
if "anvil" in config.cmd.lower():
|
|
333
|
+
if "port" in settings:
|
|
334
|
+
cmd_parts.extend(["--port", str(settings["port"])])
|
|
335
|
+
if "fork" in settings:
|
|
336
|
+
# Use _expand_env_var for consistency with config loading
|
|
337
|
+
from brawny.networks.config import _expand_env_var
|
|
338
|
+
fork_url = _expand_env_var(str(settings["fork"]), network_id=config.id)
|
|
339
|
+
if fork_url:
|
|
340
|
+
cmd_parts.extend(["--fork-url", fork_url])
|
|
341
|
+
if "fork_block" in settings:
|
|
342
|
+
cmd_parts.extend(["--fork-block-number", str(settings["fork_block"])])
|
|
343
|
+
if "accounts" in settings:
|
|
344
|
+
cmd_parts.extend(["--accounts", str(settings["accounts"])])
|
|
345
|
+
if "balance" in settings:
|
|
346
|
+
cmd_parts.extend(["--balance", str(settings["balance"])])
|
|
347
|
+
if "chain_id" in settings:
|
|
348
|
+
cmd_parts.extend(["--chain-id", str(settings["chain_id"])])
|
|
349
|
+
|
|
350
|
+
elif "ganache" in config.cmd.lower():
|
|
351
|
+
if "port" in settings:
|
|
352
|
+
cmd_parts.extend(["--port", str(settings["port"])])
|
|
353
|
+
if "fork" in settings:
|
|
354
|
+
from brawny.networks.config import _expand_env_var
|
|
355
|
+
fork_url = _expand_env_var(str(settings["fork"]), network_id=config.id)
|
|
356
|
+
if fork_url:
|
|
357
|
+
cmd_parts.extend(["--fork", fork_url])
|
|
358
|
+
if "accounts" in settings:
|
|
359
|
+
cmd_parts.extend(["--accounts", str(settings["accounts"])])
|
|
360
|
+
if "chain_id" in settings:
|
|
361
|
+
cmd_parts.extend(["--chainId", str(settings["chain_id"])])
|
|
362
|
+
|
|
363
|
+
return cmd_parts
|
|
364
|
+
|
|
365
|
+
def _wait_for_rpc(self, host: str, timeout: int = 30) -> None:
|
|
366
|
+
"""Wait for RPC to become responsive.
|
|
367
|
+
|
|
368
|
+
Uses httpx for consistency with existing console.py implementation.
|
|
369
|
+
"""
|
|
370
|
+
import httpx
|
|
371
|
+
|
|
372
|
+
start = time.time()
|
|
373
|
+
while time.time() - start < timeout:
|
|
374
|
+
# Check if process died
|
|
375
|
+
if self._rpc_process and self._rpc_process.poll() is not None:
|
|
376
|
+
raise RuntimeError("RPC process exited unexpectedly")
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
resp = httpx.post(
|
|
380
|
+
host,
|
|
381
|
+
json={
|
|
382
|
+
"jsonrpc": "2.0",
|
|
383
|
+
"method": "eth_blockNumber",
|
|
384
|
+
"params": [],
|
|
385
|
+
"id": 1,
|
|
386
|
+
},
|
|
387
|
+
timeout=2.0,
|
|
388
|
+
)
|
|
389
|
+
if resp.status_code == 200:
|
|
390
|
+
return
|
|
391
|
+
except httpx.RequestError:
|
|
392
|
+
pass
|
|
393
|
+
time.sleep(0.3)
|
|
394
|
+
|
|
395
|
+
raise TimeoutError(f"RPC at {host} not responding after {timeout}s")
|
|
396
|
+
|
|
397
|
+
def list_networks(self) -> dict[str, list[str]]:
|
|
398
|
+
"""List all available networks grouped by type."""
|
|
399
|
+
networks = self._ensure_networks()
|
|
400
|
+
return {
|
|
401
|
+
"live": [n.id for n in networks.values() if not n.is_development],
|
|
402
|
+
"development": [n.id for n in networks.values() if n.is_development],
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def rpc(self) -> RPCManager | None:
|
|
407
|
+
"""Get underlying RPCManager."""
|
|
408
|
+
return self._rpc
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# Global singleton
|
|
412
|
+
_manager: NetworkManager | None = None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _get_manager() -> NetworkManager:
|
|
416
|
+
"""Get or create network manager singleton."""
|
|
417
|
+
global _manager
|
|
418
|
+
if _manager is None:
|
|
419
|
+
_manager = NetworkManager()
|
|
420
|
+
return _manager
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
__all__ = ["NetworkManager", "_get_manager"]
|