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.
Files changed (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,633 @@
1
+ """Configuration parsing for brawny.
2
+
3
+ Provides functions to load config from YAML files and environment variables.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ from dataclasses import replace
11
+
12
+ from brawny.model.enums import KeystoreType
13
+ from brawny.model.errors import ConfigError
14
+
15
+ try:
16
+ import yaml
17
+ except ImportError: # pragma: no cover - handled by dependency management
18
+ yaml = None
19
+
20
+ # Pattern to match ${VAR_NAME}, ${VAR_NAME:-default}, or ${{VAR_NAME}} forms
21
+ ENV_VAR_PATTERN = re.compile(r"\$\{\{?([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}?\}")
22
+
23
+ _ADVANCED_FIELDS = {
24
+ "poll_interval_seconds",
25
+ "reorg_depth",
26
+ "finality_confirmations",
27
+ "default_deadline_seconds",
28
+ "stuck_tx_seconds",
29
+ "max_replacement_attempts",
30
+ "gas_limit_multiplier",
31
+ "default_priority_fee_gwei",
32
+ "max_fee_cap_gwei",
33
+ "fee_bump_percent",
34
+ "rpc_timeout_seconds",
35
+ "rpc_max_retries",
36
+ "database_pool_size",
37
+ "database_pool_max_overflow",
38
+ }
39
+
40
+ _REMOVED_ENV_KEYS = {
41
+ "ALERTS_DX_ENABLED",
42
+ "ALLOWED_SIGNERS",
43
+ "BLOCK_HASH_HISTORY_SIZE",
44
+ "ENABLE_BROWNIE_PASSWORD_FALLBACK",
45
+ "CLAIM_TIMEOUT_SECONDS",
46
+ "DB_CIRCUIT_BREAKER_FAILURES",
47
+ "DB_CIRCUIT_BREAKER_SECONDS",
48
+ "DEEP_REORG_PAUSE",
49
+ "DEEP_REORG_ALERT_ENABLED",
50
+ "ETHERSCAN_API_URL",
51
+ "INTENT_RETRY_BACKOFF_SECONDS",
52
+ "JOB_MODULES",
53
+ "JOBS_PATH",
54
+ "LOG_FORMAT",
55
+ "RPC_CIRCUIT_BREAKER_SECONDS",
56
+ "SHUTDOWN_GRACE_SECONDS",
57
+ "SHUTDOWN_TIMEOUT_SECONDS",
58
+ "MAX_FEE",
59
+ "METRICS_BIND",
60
+ "METRICS_ENABLED",
61
+ "PRIORITY_FEE",
62
+ "SOURCIFY_ENABLED",
63
+ "TELEGRAM_CHAT_IDS",
64
+ "WEBHOOK_URL",
65
+ }
66
+
67
+
68
+ def _is_chat_id(s: str) -> bool:
69
+ """Check if string looks like a raw Telegram chat ID."""
70
+ return s.lstrip("-").isdigit()
71
+
72
+
73
+ def _parse_telegram(raw: dict) -> "TelegramConfig":
74
+ """Parse telegram config, handling both old and new formats.
75
+
76
+ Normalizes all inputs (strips whitespace) and canonicalizes to TelegramConfig.
77
+ """
78
+ from brawny.config.models import TelegramConfig
79
+
80
+ # New format: telegram.bot_token, telegram.chats, telegram.default, telegram.parse_mode
81
+ if "telegram" in raw and isinstance(raw["telegram"], dict):
82
+ tg = raw["telegram"]
83
+
84
+ # Normalize bot_token
85
+ bot_token = tg.get("bot_token")
86
+ if bot_token:
87
+ bot_token = bot_token.strip()
88
+
89
+ # Normalize chats (strip keys and values, validate IDs look numeric)
90
+ raw_chats = tg.get("chats", {})
91
+ chats: dict[str, str] = {}
92
+ for k, v in raw_chats.items():
93
+ if not k or not v:
94
+ continue
95
+ k = k.strip()
96
+ v = str(v).strip()
97
+ if not _is_chat_id(v):
98
+ raise ConfigError(f"telegram.chats.{k} must be a numeric chat ID, got: '{v}'")
99
+ chats[k] = v
100
+
101
+ # Normalize default to list, strip each entry
102
+ default = tg.get("default", [])
103
+ if isinstance(default, str):
104
+ default = [default]
105
+ default = [d.strip() for d in default if d and str(d).strip()]
106
+
107
+ parse_mode = tg.get("parse_mode")
108
+ if isinstance(parse_mode, str):
109
+ parse_mode = parse_mode.strip() or None
110
+ if parse_mode not in (None, "Markdown", "MarkdownV2", "HTML"):
111
+ raise ConfigError(
112
+ "telegram.parse_mode must be one of: Markdown, MarkdownV2, HTML, or null"
113
+ )
114
+
115
+ health_chat = tg.get("health_chat")
116
+ if isinstance(health_chat, str):
117
+ health_chat = health_chat.strip() or None
118
+
119
+ health_cooldown = tg.get("health_cooldown_seconds")
120
+ if health_cooldown is not None:
121
+ try:
122
+ health_cooldown = int(health_cooldown)
123
+ except (TypeError, ValueError) as exc:
124
+ raise ConfigError("telegram.health_cooldown_seconds must be an integer") from exc
125
+
126
+ if health_cooldown is None:
127
+ health_cooldown = TelegramConfig().health_cooldown_seconds
128
+
129
+ return TelegramConfig(
130
+ bot_token=bot_token,
131
+ chats=chats,
132
+ default=default,
133
+ parse_mode=parse_mode,
134
+ health_chat=health_chat,
135
+ health_cooldown_seconds=health_cooldown,
136
+ )
137
+
138
+ # Legacy format: telegram_bot_token, telegram_chat_id
139
+ bot_token = raw.get("telegram_bot_token")
140
+ chat_id = raw.get("telegram_chat_id")
141
+
142
+ if bot_token:
143
+ bot_token = str(bot_token).strip()
144
+ if chat_id:
145
+ chat_id = str(chat_id).strip()
146
+ # Validate legacy chat_id is numeric too
147
+ if not _is_chat_id(chat_id):
148
+ raise ConfigError(f"telegram_chat_id must be numeric, got: '{chat_id}'")
149
+
150
+ if bot_token or chat_id:
151
+ # Migrate to canonical form
152
+ chats = {"default": chat_id} if chat_id else {}
153
+ default = ["default"] if chat_id else []
154
+ return TelegramConfig(
155
+ bot_token=bot_token,
156
+ chats=chats,
157
+ default=default,
158
+ )
159
+
160
+ return TelegramConfig()
161
+
162
+
163
+
164
+
165
+ def _interpolate_env_vars(
166
+ value: object,
167
+ missing: list[str] | None = None,
168
+ path: str = "",
169
+ ) -> object:
170
+ """Recursively interpolate ${VAR}, ${VAR:-default}, and ${{VAR}} patterns in config values.
171
+
172
+ Supports:
173
+ - ${VAR_NAME} / ${{VAR_NAME}} - replaced with env var value, empty string if not set
174
+ - ${VAR_NAME:-default} / ${{VAR_NAME:-default}} - replaced with env var value, or default if not set
175
+
176
+ Args:
177
+ value: Config value (string, list, dict, or primitive)
178
+
179
+ Returns:
180
+ Value with environment variables interpolated
181
+ """
182
+ if isinstance(value, str):
183
+ def replacer(match: re.Match[str]) -> str:
184
+ var_name = match.group(1)
185
+ default_val = match.group(2) # None if no default specified
186
+ env_val = os.environ.get(var_name)
187
+ if env_val is not None:
188
+ return env_val
189
+ if default_val is not None:
190
+ return default_val
191
+ if missing is not None:
192
+ location = path or "<root>"
193
+ missing.append(f"{var_name} (at {location})")
194
+ return "" # Return empty string for unset vars without default
195
+
196
+ result = ENV_VAR_PATTERN.sub(replacer, value)
197
+ # If the entire string was a variable that resolved to empty, return None
198
+ # This allows filtering out empty RPC endpoints
199
+ if result == "" and ENV_VAR_PATTERN.search(value):
200
+ return None
201
+ return result
202
+
203
+ elif isinstance(value, list):
204
+ interpolated = [
205
+ _interpolate_env_vars(item, missing, f"{path}[{idx}]")
206
+ for idx, item in enumerate(value)
207
+ ]
208
+ # Filter out None values (unset env vars) and empty strings from lists
209
+ return [v for v in interpolated if v is not None and v != ""]
210
+
211
+ elif isinstance(value, dict):
212
+ return {
213
+ k: _interpolate_env_vars(v, missing, f"{path}.{k}" if path else str(k))
214
+ for k, v in value.items()
215
+ }
216
+
217
+ else:
218
+ return value
219
+
220
+
221
+ def _get_env(key: str, default: str | None = None, required: bool = False) -> str | None:
222
+ """Get environment variable with BRAWNY_ prefix."""
223
+ full_key = f"BRAWNY_{key}"
224
+ value = os.environ.get(full_key, default)
225
+ if required and not value:
226
+ raise ConfigError(f"Required environment variable {full_key} is not set")
227
+ return value
228
+
229
+
230
+ def _env_is_set(key: str) -> bool:
231
+ return f"BRAWNY_{key}" in os.environ
232
+
233
+
234
+ def _fail_removed_env_vars() -> None:
235
+ removed = [key for key in _REMOVED_ENV_KEYS if _env_is_set(key)]
236
+ if removed:
237
+ raise ConfigError(
238
+ "Removed config options detected in environment: "
239
+ f"{sorted(removed)}. These options no longer exist."
240
+ )
241
+
242
+
243
+ def _get_env_list(key: str, default: list[str] | None = None) -> list[str]:
244
+ """Get comma-separated list from environment variable."""
245
+ value = _get_env(key)
246
+ if not value:
247
+ return default or []
248
+ return [item.strip() for item in value.split(",") if item.strip()]
249
+
250
+
251
+ def _get_env_int(key: str, default: int) -> int:
252
+ """Get integer from environment variable."""
253
+ value = _get_env(key)
254
+ if not value:
255
+ return default
256
+ try:
257
+ return int(value)
258
+ except ValueError:
259
+ raise ConfigError(f"BRAWNY_{key} must be an integer, got: {value}")
260
+
261
+
262
+ def _get_env_float(key: str, default: float) -> float:
263
+ """Get float from environment variable."""
264
+ value = _get_env(key)
265
+ if not value:
266
+ return default
267
+ try:
268
+ return float(value)
269
+ except ValueError:
270
+ raise ConfigError(f"BRAWNY_{key} must be a number, got: {value}")
271
+
272
+
273
+ def _parse_env_int(key: str) -> int:
274
+ value = _get_env(key)
275
+ if value is None:
276
+ raise ConfigError(f"Missing env override BRAWNY_{key}")
277
+ try:
278
+ return int(value)
279
+ except ValueError:
280
+ raise ConfigError(f"BRAWNY_{key} must be an integer, got: {value}")
281
+
282
+
283
+ def _parse_env_float(key: str) -> float:
284
+ value = _get_env(key)
285
+ if value is None:
286
+ raise ConfigError(f"Missing env override BRAWNY_{key}")
287
+ try:
288
+ return float(value)
289
+ except ValueError:
290
+ raise ConfigError(f"BRAWNY_{key} must be a number, got: {value}")
291
+
292
+
293
+ def from_env() -> "Config":
294
+ """Load configuration from environment variables."""
295
+ from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig
296
+ from brawny.config.validation import canonicalize_endpoint, dedupe_preserve_order, InvalidEndpointError
297
+
298
+ _fail_removed_env_vars()
299
+
300
+ # Get required values
301
+ database_url = _get_env("DATABASE_URL", required=True)
302
+ if database_url is None:
303
+ raise ConfigError("BRAWNY_DATABASE_URL is required")
304
+
305
+ rpc_endpoints = _get_env_list("RPC_ENDPOINTS")
306
+ if not rpc_endpoints:
307
+ raise ConfigError("BRAWNY_RPC_ENDPOINTS is required (comma-separated list)")
308
+
309
+ endpoints: list[str] = []
310
+ for i, endpoint in enumerate(rpc_endpoints):
311
+ try:
312
+ endpoints.append(canonicalize_endpoint(endpoint))
313
+ except InvalidEndpointError as e:
314
+ raise ConfigError(f"rpc_endpoints[{i}]: {e}") from e
315
+ endpoints = dedupe_preserve_order(endpoints)
316
+
317
+ rpc_default_group = _get_env("RPC_DEFAULT_GROUP") or "primary"
318
+
319
+ chain_id = _get_env_int("CHAIN_ID", 1)
320
+
321
+ # Parse keystore type
322
+ keystore_type_str = _get_env("KEYSTORE_TYPE", "file")
323
+ try:
324
+ keystore_type = KeystoreType(keystore_type_str)
325
+ except ValueError:
326
+ raise ConfigError(
327
+ f"Invalid keystore type: {keystore_type_str}. "
328
+ f"Must be one of: {', '.join(kt.value for kt in KeystoreType)}"
329
+ )
330
+
331
+ advanced_kwargs: dict[str, object] = {}
332
+ if _env_is_set("POLL_INTERVAL_SECONDS"):
333
+ advanced_kwargs["poll_interval_seconds"] = _parse_env_float("POLL_INTERVAL_SECONDS")
334
+ if _env_is_set("REORG_DEPTH"):
335
+ advanced_kwargs["reorg_depth"] = _parse_env_int("REORG_DEPTH")
336
+ if _env_is_set("FINALITY_CONFIRMATIONS"):
337
+ advanced_kwargs["finality_confirmations"] = _parse_env_int("FINALITY_CONFIRMATIONS")
338
+ if _env_is_set("DEFAULT_DEADLINE_SECONDS"):
339
+ advanced_kwargs["default_deadline_seconds"] = _parse_env_int("DEFAULT_DEADLINE_SECONDS")
340
+ if _env_is_set("STUCK_TX_SECONDS"):
341
+ advanced_kwargs["stuck_tx_seconds"] = _parse_env_int("STUCK_TX_SECONDS")
342
+ if _env_is_set("MAX_REPLACEMENT_ATTEMPTS"):
343
+ advanced_kwargs["max_replacement_attempts"] = _parse_env_int("MAX_REPLACEMENT_ATTEMPTS")
344
+ if _env_is_set("GAS_LIMIT_MULTIPLIER"):
345
+ advanced_kwargs["gas_limit_multiplier"] = _parse_env_float("GAS_LIMIT_MULTIPLIER")
346
+ if _env_is_set("DEFAULT_PRIORITY_FEE_GWEI"):
347
+ advanced_kwargs["default_priority_fee_gwei"] = _parse_env_float("DEFAULT_PRIORITY_FEE_GWEI")
348
+ if _env_is_set("MAX_FEE_CAP_GWEI"):
349
+ advanced_kwargs["max_fee_cap_gwei"] = _parse_env_float("MAX_FEE_CAP_GWEI")
350
+ if _env_is_set("FEE_BUMP_PERCENT"):
351
+ advanced_kwargs["fee_bump_percent"] = _parse_env_int("FEE_BUMP_PERCENT")
352
+ if _env_is_set("RPC_TIMEOUT_SECONDS"):
353
+ advanced_kwargs["rpc_timeout_seconds"] = _parse_env_float("RPC_TIMEOUT_SECONDS")
354
+ if _env_is_set("RPC_MAX_RETRIES"):
355
+ advanced_kwargs["rpc_max_retries"] = _parse_env_int("RPC_MAX_RETRIES")
356
+ if _env_is_set("DATABASE_POOL_SIZE"):
357
+ advanced_kwargs["database_pool_size"] = _parse_env_int("DATABASE_POOL_SIZE")
358
+ if _env_is_set("DATABASE_POOL_MAX_OVERFLOW"):
359
+ advanced_kwargs["database_pool_max_overflow"] = _parse_env_int("DATABASE_POOL_MAX_OVERFLOW")
360
+
361
+
362
+ # Parse telegram config from env (legacy format)
363
+ telegram_config = _parse_telegram({
364
+ "telegram_bot_token": _get_env("TELEGRAM_BOT_TOKEN"),
365
+ "telegram_chat_id": _get_env("TELEGRAM_CHAT_ID"),
366
+ })
367
+
368
+ config = Config(
369
+ database_url=database_url,
370
+ rpc_endpoints=endpoints,
371
+ rpc_groups={rpc_default_group: RPCGroupConfig(endpoints=endpoints)},
372
+ rpc_default_group=rpc_default_group,
373
+ chain_id=chain_id,
374
+ worker_count=_get_env_int("WORKER_COUNT", 1),
375
+ advanced=AdvancedConfig(**advanced_kwargs) if advanced_kwargs else None,
376
+ telegram=telegram_config,
377
+ keystore_type=keystore_type,
378
+ keystore_path=_get_env("KEYSTORE_PATH", "~/.brawny/keys") or "~/.brawny/keys",
379
+ )
380
+
381
+ config.validate()
382
+ return config
383
+
384
+
385
+ def from_yaml(path: str) -> "Config":
386
+ """Load configuration from a YAML file.
387
+
388
+ Supports environment variable interpolation using ${VAR}, ${{VAR}}, or ${VAR:-default} syntax.
389
+ For example:
390
+ rpc_groups:
391
+ primary:
392
+ endpoints:
393
+ - ${RPC_1}
394
+ - ${RPC_2:-http://localhost:8545}
395
+ - ${{RPC_3}}
396
+ rpc_default_group: primary
397
+
398
+ Empty/unset variables in lists are automatically filtered out.
399
+ """
400
+ from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig
401
+ from brawny.config.validation import (
402
+ canonicalize_endpoint,
403
+ dedupe_preserve_order,
404
+ InvalidEndpointError,
405
+ validate_no_removed_fields,
406
+ )
407
+
408
+ if yaml is None:
409
+ raise ConfigError("PyYAML is required for YAML config support.")
410
+ try:
411
+ with open(path, "r", encoding="utf-8") as handle:
412
+ data = yaml.safe_load(handle) or {}
413
+ except FileNotFoundError as e:
414
+ raise ConfigError(f"Config file not found: {path}") from e
415
+ except Exception as e:
416
+ raise ConfigError(f"Failed to read config file {path}: {e}") from e
417
+
418
+ if not isinstance(data, dict):
419
+ raise ConfigError("Config file must contain a mapping at the top level.")
420
+
421
+ # Interpolate environment variables in all config values
422
+ missing: list[str] = []
423
+ data = _interpolate_env_vars(data, missing)
424
+ if not isinstance(data, dict):
425
+ raise ConfigError("Config interpolation failed.")
426
+ if missing:
427
+ missing_list = ", ".join(sorted(set(missing)))
428
+ raise ConfigError(
429
+ f"Config interpolation failed. Missing environment variables: {missing_list}"
430
+ )
431
+
432
+ validate_no_removed_fields(data)
433
+
434
+ advanced_data: dict[str, object] = {}
435
+ if "advanced" in data:
436
+ advanced_value = data.pop("advanced")
437
+ if advanced_value is None:
438
+ advanced_value = {}
439
+ if not isinstance(advanced_value, dict):
440
+ raise ConfigError("advanced must be a mapping")
441
+ advanced_data = dict(advanced_value)
442
+
443
+ # Parse rpc_groups with canonicalization + deduplication
444
+ rpc_groups_data = data.pop("rpc_groups", {})
445
+ rpc_groups: dict[str, RPCGroupConfig] = {}
446
+
447
+ if rpc_groups_data:
448
+ for name, group_data in rpc_groups_data.items():
449
+ if not isinstance(group_data, dict):
450
+ raise ConfigError(f"rpc_groups.{name} must be a mapping")
451
+
452
+ endpoints_raw = group_data.get("endpoints", [])
453
+ if not isinstance(endpoints_raw, list):
454
+ raise ConfigError(f"rpc_groups.{name}.endpoints must be a list")
455
+
456
+ # Strip → canonicalize → dedupe (once, here)
457
+ # InvalidEndpointError from canonicalize → ConfigError with context
458
+ endpoints = []
459
+ for i, ep in enumerate(endpoints_raw):
460
+ if not isinstance(ep, str):
461
+ raise ConfigError(f"rpc_groups.{name}.endpoints[{i}] must be string")
462
+ # Skip empty strings (from unset env vars)
463
+ if not ep.strip():
464
+ continue
465
+ try:
466
+ canonical = canonicalize_endpoint(ep)
467
+ endpoints.append(canonical)
468
+ except InvalidEndpointError as e:
469
+ raise ConfigError(f"rpc_groups.{name}.endpoints[{i}]: {e}") from e
470
+
471
+ original_count = len(endpoints)
472
+ endpoints = dedupe_preserve_order(endpoints)
473
+ if len(endpoints) != original_count:
474
+ # Log warning about deduplication (will be logged at config load time)
475
+ import logging
476
+ logging.getLogger(__name__).warning(
477
+ f"rpc_groups.{name}: removed {original_count - len(endpoints)} "
478
+ f"duplicate endpoint(s) after canonicalization"
479
+ )
480
+
481
+ rpc_groups[name] = RPCGroupConfig(endpoints=endpoints)
482
+
483
+ data["rpc_groups"] = rpc_groups
484
+
485
+ # Optional default group for routing (may be None)
486
+ data["rpc_default_group"] = data.get("rpc_default_group")
487
+
488
+ # Derive rpc_endpoints from default or single group (internal use)
489
+ default_group = data.get("rpc_default_group")
490
+ if default_group and default_group in rpc_groups:
491
+ data["rpc_endpoints"] = rpc_groups[default_group].endpoints
492
+ elif len(rpc_groups) == 1:
493
+ data["rpc_endpoints"] = next(iter(rpc_groups.values())).endpoints
494
+ else:
495
+ data["rpc_endpoints"] = []
496
+
497
+ if advanced_data:
498
+ unknown = set(advanced_data.keys()) - _ADVANCED_FIELDS
499
+ if unknown:
500
+ raise ConfigError(f"Unknown advanced config fields: {sorted(unknown)}")
501
+ data["advanced"] = AdvancedConfig(**advanced_data)
502
+
503
+ # Parse telegram config (handles both new and legacy formats)
504
+ telegram_config = _parse_telegram(data)
505
+ # Remove raw telegram fields - they've been canonicalized
506
+ data.pop("telegram", None)
507
+ data.pop("telegram_bot_token", None)
508
+ data.pop("telegram_chat_id", None)
509
+ data["telegram"] = telegram_config
510
+
511
+ config = Config(**data)
512
+ config.validate()
513
+ return config
514
+
515
+
516
+ def apply_env_overrides(config: "Config") -> tuple["Config", list[str]]:
517
+ """Apply environment overrides to the current config."""
518
+ from brawny.config.models import AdvancedConfig, Config, RPCGroupConfig, TelegramConfig
519
+ from brawny.config.validation import canonicalize_endpoint, dedupe_preserve_order, InvalidEndpointError
520
+
521
+ _fail_removed_env_vars()
522
+
523
+ overrides: dict[str, object] = {}
524
+ advanced_overrides: dict[str, object] = {}
525
+ overridden: list[str] = []
526
+
527
+ mapping = {
528
+ "DATABASE_URL": ("database_url", _get_env),
529
+ "RPC_DEFAULT_GROUP": ("rpc_default_group", _get_env),
530
+ "CHAIN_ID": ("chain_id", _parse_env_int),
531
+ "WORKER_COUNT": ("worker_count", _parse_env_int),
532
+ "KEYSTORE_TYPE": ("keystore_type", _get_env),
533
+ "KEYSTORE_PATH": ("keystore_path", _get_env),
534
+ }
535
+
536
+ for env_key, (field_name, parser) in mapping.items():
537
+ if not _env_is_set(env_key):
538
+ continue
539
+ value = parser(env_key)
540
+ if value is None:
541
+ continue
542
+ overrides[field_name] = value
543
+ overridden.append(field_name)
544
+
545
+ # Handle telegram env overrides (legacy format)
546
+ telegram_token_override = _get_env("TELEGRAM_BOT_TOKEN") if _env_is_set("TELEGRAM_BOT_TOKEN") else None
547
+ telegram_chat_override = _get_env("TELEGRAM_CHAT_ID") if _env_is_set("TELEGRAM_CHAT_ID") else None
548
+ if telegram_token_override is not None or telegram_chat_override is not None:
549
+ # Build new telegram config merging with existing
550
+ base_telegram = config.telegram
551
+ new_token = telegram_token_override.strip() if telegram_token_override else base_telegram.bot_token
552
+ new_chats = dict(base_telegram.chats)
553
+ new_default = list(base_telegram.default)
554
+
555
+ if telegram_chat_override:
556
+ chat_id = telegram_chat_override.strip()
557
+ if not _is_chat_id(chat_id):
558
+ raise ConfigError(f"BRAWNY_TELEGRAM_CHAT_ID must be numeric, got: '{chat_id}'")
559
+ new_chats["default"] = chat_id
560
+ if "default" not in new_default:
561
+ new_default = ["default"] + new_default
562
+
563
+ overrides["telegram"] = TelegramConfig(
564
+ bot_token=new_token,
565
+ chats=new_chats,
566
+ default=new_default,
567
+ parse_mode=base_telegram.parse_mode,
568
+ health_chat=base_telegram.health_chat,
569
+ health_cooldown_seconds=base_telegram.health_cooldown_seconds,
570
+ )
571
+ if telegram_token_override is not None:
572
+ overridden.append("telegram.bot_token")
573
+ if telegram_chat_override is not None:
574
+ overridden.append("telegram.chat_id")
575
+
576
+ advanced_mapping = {
577
+ "POLL_INTERVAL_SECONDS": ("poll_interval_seconds", _parse_env_float),
578
+ "REORG_DEPTH": ("reorg_depth", _parse_env_int),
579
+ "FINALITY_CONFIRMATIONS": ("finality_confirmations", _parse_env_int),
580
+ "DEFAULT_DEADLINE_SECONDS": ("default_deadline_seconds", _parse_env_int),
581
+ "STUCK_TX_SECONDS": ("stuck_tx_seconds", _parse_env_int),
582
+ "MAX_REPLACEMENT_ATTEMPTS": ("max_replacement_attempts", _parse_env_int),
583
+ "GAS_LIMIT_MULTIPLIER": ("gas_limit_multiplier", _parse_env_float),
584
+ "DEFAULT_PRIORITY_FEE_GWEI": ("default_priority_fee_gwei", _parse_env_float),
585
+ "MAX_FEE_CAP_GWEI": ("max_fee_cap_gwei", _parse_env_float),
586
+ "FEE_BUMP_PERCENT": ("fee_bump_percent", _parse_env_int),
587
+ "RPC_TIMEOUT_SECONDS": ("rpc_timeout_seconds", _parse_env_float),
588
+ "RPC_MAX_RETRIES": ("rpc_max_retries", _parse_env_int),
589
+ "DATABASE_POOL_SIZE": ("database_pool_size", _parse_env_int),
590
+ "DATABASE_POOL_MAX_OVERFLOW": ("database_pool_max_overflow", _parse_env_int),
591
+ }
592
+
593
+ for env_key, (field_name, parser) in advanced_mapping.items():
594
+ if not _env_is_set(env_key):
595
+ continue
596
+ value = parser(env_key)
597
+ advanced_overrides[field_name] = value
598
+ overridden.append(f"advanced.{field_name}")
599
+
600
+ rpc_endpoints_override: list[str] | None = None
601
+ if _env_is_set("RPC_ENDPOINTS"):
602
+ raw_endpoints = _get_env_list("RPC_ENDPOINTS")
603
+ endpoints: list[str] = []
604
+ for i, endpoint in enumerate(raw_endpoints):
605
+ try:
606
+ endpoints.append(canonicalize_endpoint(endpoint))
607
+ except InvalidEndpointError as e:
608
+ raise ConfigError(f"rpc_endpoints[{i}]: {e}") from e
609
+ rpc_endpoints_override = dedupe_preserve_order(endpoints)
610
+
611
+ if not overrides:
612
+ return config, []
613
+
614
+ if "keystore_type" in overrides:
615
+ overrides["keystore_type"] = KeystoreType(str(overrides["keystore_type"]))
616
+
617
+ if advanced_overrides:
618
+ base_advanced = config.advanced or AdvancedConfig()
619
+ overrides["advanced"] = replace(base_advanced, **advanced_overrides)
620
+
621
+ if rpc_endpoints_override is not None:
622
+ default_group = overrides.get("rpc_default_group") or config.rpc_default_group or "primary"
623
+ overrides["rpc_default_group"] = default_group
624
+ overrides["rpc_endpoints"] = rpc_endpoints_override
625
+ overrides["rpc_groups"] = {default_group: RPCGroupConfig(endpoints=rpc_endpoints_override)}
626
+ overridden.extend(["rpc_endpoints", "rpc_groups"])
627
+
628
+ if "rpc_default_group" in overrides:
629
+ default_group = str(overrides["rpc_default_group"])
630
+ if default_group in config.rpc_groups:
631
+ overrides["rpc_endpoints"] = config.rpc_groups[default_group].endpoints
632
+
633
+ return replace(config, **overrides), overridden
@@ -0,0 +1,55 @@
1
+ """RPC group routing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from brawny.config import Config
9
+ from brawny.jobs.base import Job
10
+
11
+
12
+ def resolve_default_group(config: "Config") -> str:
13
+ """Resolve the default RPC group.
14
+
15
+ Rules:
16
+ - If rpc_default_group is set, use it.
17
+ - If exactly one rpc_group exists, use it.
18
+ - If multiple rpc_groups and no default, raise.
19
+ """
20
+ if config.rpc_default_group:
21
+ return config.rpc_default_group
22
+
23
+ if len(config.rpc_groups) == 1:
24
+ return next(iter(config.rpc_groups.keys()))
25
+
26
+ if not config.rpc_groups:
27
+ raise ValueError("rpc_groups not configured; set rpc_groups and rpc_default_group")
28
+
29
+ raise ValueError("Multiple rpc_groups configured; set rpc_default_group.")
30
+
31
+
32
+ def resolve_job_groups(config: "Config", job: "Job") -> tuple[str, str]:
33
+ """Resolve read/broadcast groups for a job.
34
+
35
+ Returns:
36
+ (read_group, broadcast_group)
37
+ """
38
+ read_group = getattr(job, "_read_group", None)
39
+ broadcast_group = getattr(job, "_broadcast_group", None)
40
+
41
+ if not config.rpc_groups:
42
+ raise ValueError("rpc_groups not configured; set rpc_groups and rpc_default_group")
43
+
44
+ default_group = resolve_default_group(config)
45
+ if read_group is None:
46
+ read_group = default_group
47
+ if broadcast_group is None:
48
+ broadcast_group = default_group
49
+
50
+ if read_group not in config.rpc_groups:
51
+ raise ValueError(f"read_group '{read_group}' not found in rpc_groups")
52
+ if broadcast_group not in config.rpc_groups:
53
+ raise ValueError(f"broadcast_group '{broadcast_group}' not found in rpc_groups")
54
+
55
+ return read_group, broadcast_group