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
brawny/alerts/send.py ADDED
@@ -0,0 +1,364 @@
1
+ """Simplified alert system.
2
+
3
+ Send alerts to Telegram or webhooks. No classes. No inheritance. No plugin architecture.
4
+
5
+ Usage:
6
+ payload = AlertPayload(
7
+ job_id="my-job",
8
+ job_name="My Job",
9
+ event_type=AlertEvent.CONFIRMED,
10
+ message="Transaction confirmed!",
11
+ )
12
+ config = AlertConfig(
13
+ telegram_token="...",
14
+ telegram_chat_ids=["123456"],
15
+ )
16
+ await send_alert(payload, config)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import threading
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime
25
+ from enum import Enum
26
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine
27
+
28
+ if TYPE_CHECKING:
29
+ from brawny.telegram import TelegramBot
30
+
31
+ import httpx
32
+
33
+ from brawny.logging import get_logger
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ class AlertEvent(str, Enum):
39
+ """Alert event types. Aligned with OE2 hook reduction."""
40
+
41
+ TRIGGERED = "triggered"
42
+ CONFIRMED = "confirmed"
43
+ FAILED = "failed"
44
+
45
+
46
+ @dataclass
47
+ class AlertPayload:
48
+ """Data object for alert content."""
49
+
50
+ job_id: str
51
+ job_name: str
52
+ event_type: AlertEvent
53
+ message: str
54
+ parse_mode: str = "Markdown"
55
+ chain_id: int = 1
56
+ timestamp: datetime = field(default_factory=datetime.utcnow)
57
+
58
+
59
+ @dataclass
60
+ class AlertConfig:
61
+ """Transport configuration. Passed once, not spread across callsites."""
62
+
63
+ telegram_token: str | None = None
64
+ telegram_chat_ids: list[str] = field(default_factory=list)
65
+ webhook_url: str | None = None
66
+ rate_limit_seconds: float = 3.0
67
+
68
+
69
+ # Module-level state for rate limiting only
70
+ # NOTE: No module-level httpx.AsyncClient - asyncio objects are not safe to share
71
+ # across multiple event loops / loop lifetimes. For low-volume alerts, we create
72
+ # a fresh client per request (httpx context manager handles cleanup).
73
+ _last_sent: dict[str, datetime] = {}
74
+ # Use threading.Lock, not asyncio.Lock - avoids event loop binding issues
75
+ _last_sent_lock = threading.Lock()
76
+
77
+
78
+ async def send_alert(payload: AlertPayload, config: AlertConfig) -> None:
79
+ """Send alert to configured destinations. Fire-and-forget."""
80
+ tasks: list[Coroutine[Any, Any, None]] = []
81
+
82
+ if config.telegram_token and config.telegram_chat_ids:
83
+ for chat_id in config.telegram_chat_ids:
84
+ if _should_send(payload, "telegram", chat_id, config.rate_limit_seconds):
85
+ tasks.append(_send_telegram(config.telegram_token, chat_id, payload))
86
+
87
+ if config.webhook_url:
88
+ if _should_send(payload, "webhook", config.webhook_url, config.rate_limit_seconds):
89
+ tasks.append(_send_webhook(config.webhook_url, payload))
90
+
91
+ if tasks:
92
+ results = await asyncio.gather(*tasks, return_exceptions=True)
93
+ for i, result in enumerate(results):
94
+ if isinstance(result, Exception):
95
+ _log_failure(payload, tasks[i], result)
96
+
97
+
98
+ def _should_send(
99
+ payload: AlertPayload,
100
+ dest_type: str,
101
+ destination_id: str,
102
+ limit_seconds: float,
103
+ ) -> bool:
104
+ """Check rate limit. Key includes dest_type to avoid collisions.
105
+
106
+ Key format: job_id:event_type:dest_type:destination_id
107
+ - Multiple chat IDs rate-limited independently
108
+ - Telegram + webhook don't suppress each other
109
+ - dest_type prevents test collisions
110
+
111
+ Uses threading.Lock (not asyncio.Lock) to avoid event loop binding issues.
112
+ """
113
+ key = f"{payload.job_id}:{payload.event_type.value}:{dest_type}:{destination_id}"
114
+
115
+ with _last_sent_lock:
116
+ now = datetime.utcnow()
117
+ if key in _last_sent:
118
+ if (now - _last_sent[key]).total_seconds() < limit_seconds:
119
+ return False
120
+ _last_sent[key] = now
121
+ return True
122
+
123
+
124
+ async def _send_telegram(token: str, chat_id: str, payload: AlertPayload) -> None:
125
+ """Send message to Telegram. Pure function, no state."""
126
+ parse_mode = payload.parse_mode or "Markdown"
127
+ url = f"https://api.telegram.org/bot{token}/sendMessage"
128
+ data = {
129
+ "chat_id": chat_id,
130
+ "text": payload.message,
131
+ "parse_mode": parse_mode,
132
+ "disable_web_page_preview": True,
133
+ }
134
+ async with httpx.AsyncClient(timeout=10.0) as client:
135
+ resp = await client.post(url, json=data)
136
+ resp.raise_for_status()
137
+
138
+
139
+ async def _send_webhook(url: str, payload: AlertPayload) -> None:
140
+ """Send payload to webhook. Pure function, no state.
141
+
142
+ Schema (frozen):
143
+ - job_id: str
144
+ - job_name: str
145
+ - event_type: str (enum value)
146
+ - message: str
147
+ - chain_id: int
148
+ - timestamp: str (ISO8601 UTC)
149
+
150
+ Do not add fields without versioning discussion.
151
+ """
152
+ async with httpx.AsyncClient(timeout=10.0) as client:
153
+ resp = await client.post(
154
+ url,
155
+ json={
156
+ "job_id": payload.job_id,
157
+ "job_name": payload.job_name,
158
+ "event_type": payload.event_type.value,
159
+ "message": payload.message,
160
+ "chain_id": payload.chain_id,
161
+ "timestamp": payload.timestamp.isoformat() + "Z",
162
+ },
163
+ )
164
+ resp.raise_for_status()
165
+
166
+
167
+ def _log_failure(payload: AlertPayload, task: Coroutine[Any, Any, None], error: Exception) -> None:
168
+ """Log alert failure with enough context to debug."""
169
+ task_name = task.__qualname__ if hasattr(task, "__qualname__") else str(task)
170
+
171
+ if "telegram" in task_name.lower():
172
+ logger.warning(
173
+ "alert_delivery_failed",
174
+ job_id=payload.job_id,
175
+ event_type=payload.event_type.value,
176
+ destination="telegram",
177
+ error=str(error),
178
+ )
179
+ elif "webhook" in task_name.lower():
180
+ logger.warning(
181
+ "alert_delivery_failed",
182
+ job_id=payload.job_id,
183
+ event_type=payload.event_type.value,
184
+ destination="webhook",
185
+ error=str(error),
186
+ )
187
+ else:
188
+ logger.warning(
189
+ "alert_delivery_failed",
190
+ job_id=payload.job_id,
191
+ event_type=payload.event_type.value,
192
+ error=str(error),
193
+ )
194
+
195
+
196
+ # =============================================================================
197
+ # Public alert() Function for Job Hooks
198
+ # =============================================================================
199
+
200
+
201
+ def alert(
202
+ message: str,
203
+ *,
204
+ to: str | list[str] | None = None,
205
+ parse_mode: str | None = None,
206
+ disable_web_page_preview: bool = True,
207
+ disable_notification: bool = False,
208
+ ) -> None:
209
+ """Send alert from within a job hook.
210
+
211
+ Handles routing resolution, then delegates to TelegramBot.send_message().
212
+
213
+ Uses Telegram Bot API parameter names verbatim. No aliases or renaming.
214
+ Refer to https://core.telegram.org/bots/api#sendmessage for parameter docs.
215
+
216
+ Args:
217
+ message: Alert text (up to 4096 characters, auto-truncated)
218
+ to: Override routing target (name, raw ID, or list). If None,
219
+ uses job's alert_to config, then global default.
220
+ Note: This is a routing concept, not a Telegram API field.
221
+ parse_mode: "Markdown", "MarkdownV2", "HTML", or None
222
+ disable_web_page_preview: Disable link previews (default True)
223
+ disable_notification: Send without notification sound (default False)
224
+
225
+ Resolution order:
226
+ 1. `to` parameter (if provided)
227
+ 2. Job's alert_to config (if set)
228
+ 3. Global default (from config)
229
+
230
+ Raises:
231
+ RuntimeError: If called outside a job hook
232
+
233
+ Note:
234
+ Unknown chat names are logged and skipped (not raised). Startup
235
+ validation catches typos during normal deployment; runtime errors
236
+ are logged but don't crash hooks.
237
+
238
+ Example:
239
+ alert("Harvested successfully")
240
+ alert("Debug info", to="dev", disable_notification=True)
241
+ alert("Check https://etherscan.io/tx/...", disable_web_page_preview=False)
242
+ """
243
+ from brawny._context import get_alert_context
244
+ from brawny.alerts.routing import resolve_targets
245
+
246
+ ctx = get_alert_context()
247
+ if ctx is None:
248
+ raise RuntimeError("alert() must be called from within a job hook")
249
+
250
+ # Get telegram config and bot from context
251
+ tg_config = getattr(ctx, "telegram_config", None)
252
+ bot = getattr(ctx, "telegram_bot", None)
253
+ if not tg_config or not bot:
254
+ return # Silent no-op (warned once at startup)
255
+
256
+ # Determine target
257
+ if to is not None:
258
+ target = to
259
+ else:
260
+ job_alert_to = getattr(ctx, "job_alert_to", None)
261
+ target = job_alert_to if job_alert_to is not None else None
262
+
263
+ # Resolve to chat IDs (unknown names logged + skipped, not raised)
264
+ job_id = getattr(ctx, "job_id", None)
265
+ chat_ids = resolve_targets(
266
+ target,
267
+ tg_config.chats,
268
+ tg_config.default,
269
+ job_id=job_id,
270
+ )
271
+
272
+ if not chat_ids:
273
+ return # No targets configured (or all were invalid)
274
+
275
+ # Send to each resolved chat
276
+ for chat_id in chat_ids:
277
+ effective_parse_mode = (
278
+ parse_mode if parse_mode is not None else tg_config.parse_mode or "Markdown"
279
+ )
280
+ bot.send_message(
281
+ message,
282
+ chat_id=chat_id,
283
+ parse_mode=effective_parse_mode,
284
+ disable_web_page_preview=disable_web_page_preview,
285
+ disable_notification=disable_notification,
286
+ )
287
+
288
+
289
+ async def _send_alert_logged(payload: AlertPayload, config: AlertConfig) -> None:
290
+ """Fire-and-forget alert with exception logging."""
291
+ try:
292
+ await send_alert(payload, config)
293
+ except Exception:
294
+ logger.exception("Failed to send alert", job_id=payload.job_id)
295
+
296
+
297
+ # =============================================================================
298
+ # Health Alert Sender (distinct rate limiting from job alerts)
299
+ # =============================================================================
300
+
301
+ # Separate rate limiting for health alerts (prevents job alert noise from blocking health)
302
+ _health_last_sent: dict[str, datetime] = {}
303
+ _health_lock = threading.Lock()
304
+
305
+ HEALTH_RATE_LIMIT_SECONDS = 1.0 # Min interval between health messages to same chat
306
+
307
+
308
+ def _should_send_health(chat_id: str) -> bool:
309
+ """Check rate limit for health alerts. Uses separate namespace from job alerts."""
310
+ key = f"health:{chat_id}"
311
+ with _health_lock:
312
+ now = datetime.utcnow()
313
+ if key in _health_last_sent:
314
+ if (now - _health_last_sent[key]).total_seconds() < HEALTH_RATE_LIMIT_SECONDS:
315
+ return False
316
+ _health_last_sent[key] = now
317
+ return True
318
+
319
+
320
+ def create_send_health(bot: "TelegramBot") -> "Callable[[str, str], None]":
321
+ """Create a health alert sender bound to a TelegramBot instance.
322
+
323
+ Returns a callable that accepts (chat_id, text) kwargs.
324
+ Uses distinct rate limiting from job alerts to prevent cross-blocking.
325
+
326
+ Args:
327
+ bot: TelegramBot instance to use for sending
328
+
329
+ Returns:
330
+ Function that sends health alerts: fn(chat_id=..., text=...)
331
+
332
+ Usage:
333
+ send_fn = create_send_health(telegram_bot)
334
+ send_fn(chat_id="-100...", text="Health alert message")
335
+ """
336
+ def send_health(*, chat_id: str, text: str) -> None:
337
+ """Send a health alert via the standard pipeline.
338
+
339
+ Uses distinct rate_limit_key to prevent job alerts from blocking health alerts.
340
+ """
341
+ if not bot.configured:
342
+ return
343
+
344
+ if not _should_send_health(chat_id):
345
+ logger.debug(
346
+ "health_alert.rate_limited",
347
+ chat_id=chat_id,
348
+ )
349
+ return
350
+
351
+ try:
352
+ bot.send_message(
353
+ text,
354
+ chat_id=chat_id,
355
+ disable_web_page_preview=True,
356
+ )
357
+ except Exception as e:
358
+ logger.warning(
359
+ "health_alert.send_failed",
360
+ chat_id=chat_id,
361
+ error=str(e)[:200],
362
+ )
363
+
364
+ return send_health