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/jobs/kv.py ADDED
@@ -0,0 +1,125 @@
1
+ """Persistent job key-value storage helpers.
2
+
3
+ KV protocols are type-enforced by phase:
4
+ - KVReader (read-only): BuildContext and AlertContext
5
+ - KVStore (read+write): CheckContext only
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Protocol
11
+
12
+ from brawny.db.base import Database
13
+
14
+
15
+ class KVReader(Protocol):
16
+ """Read-only KV access for build/alert phases."""
17
+
18
+ def get(self, key: str, default: Any = None) -> Any:
19
+ """Get a value from storage."""
20
+ ...
21
+
22
+
23
+ class KVStore(KVReader, Protocol):
24
+ """Read+write KV access for check phase only."""
25
+
26
+ def set(self, key: str, value: Any) -> None:
27
+ """Set a value in storage."""
28
+ ...
29
+
30
+ def delete(self, key: str) -> bool:
31
+ """Delete a value from storage. Returns True if deleted."""
32
+ ...
33
+
34
+
35
+ class InMemoryJobKVStore:
36
+ """In-memory KV store for tests or fallback usage.
37
+
38
+ Implements both KVStore and KVReader protocols.
39
+ """
40
+
41
+ def __init__(self) -> None:
42
+ self._data: dict[str, Any] = {}
43
+
44
+ def get(self, key: str, default: Any = None) -> Any:
45
+ """Get a value from the in-memory store."""
46
+ return self._data.get(key, default)
47
+
48
+ def set(self, key: str, value: Any) -> None:
49
+ """Set a value in the in-memory store."""
50
+ self._data[key] = value
51
+
52
+ def delete(self, key: str) -> bool:
53
+ """Delete a value from the in-memory store."""
54
+ return self._data.pop(key, None) is not None
55
+
56
+
57
+ class DatabaseJobKVReader:
58
+ """Read-only job KV access backed by the database.
59
+
60
+ Used for SuccessContext and FailureContext where writes are not allowed.
61
+ """
62
+
63
+ _MISSING = object()
64
+
65
+ def __init__(self, db: Database, job_id: str) -> None:
66
+ self._db = db
67
+ self._job_id = job_id
68
+ self._cache: dict[str, Any] = {}
69
+
70
+ def get(self, key: str, default: Any = None) -> Any:
71
+ """Get a value from persistent storage."""
72
+ if key in self._cache:
73
+ cached = self._cache[key]
74
+ if cached is self._MISSING:
75
+ return default
76
+ return cached
77
+
78
+ value = self._db.get_job_kv(self._job_id, key)
79
+ if value is None:
80
+ self._cache[key] = self._MISSING
81
+ return default
82
+
83
+ self._cache[key] = value
84
+ return value
85
+
86
+
87
+ class DatabaseJobKVStore:
88
+ """Job KV store backed by the database with a small read cache."""
89
+
90
+ _MISSING = object()
91
+
92
+ def __init__(self, db: Database, job_id: str) -> None:
93
+ self._db = db
94
+ self._job_id = job_id
95
+ self._cache: dict[str, Any] = {}
96
+
97
+ def get(self, key: str, default: Any = None) -> Any:
98
+ """Get a value from persistent storage."""
99
+ if key in self._cache:
100
+ cached = self._cache[key]
101
+ if cached is self._MISSING:
102
+ return default
103
+ return cached
104
+
105
+ value = self._db.get_job_kv(self._job_id, key)
106
+ if value is None:
107
+ self._cache[key] = self._MISSING
108
+ return default
109
+
110
+ self._cache[key] = value
111
+ return value
112
+
113
+ def set(self, key: str, value: Any) -> None:
114
+ """Persist a value and update the cache."""
115
+ self._db.set_job_kv(self._job_id, key, value)
116
+ self._cache[key] = value
117
+
118
+ def delete(self, key: str) -> bool:
119
+ """Delete a value from persistent storage."""
120
+ deleted = self._db.delete_job_kv(self._job_id, key)
121
+ if deleted:
122
+ self._cache.pop(key, None)
123
+ else:
124
+ self._cache[key] = self._MISSING
125
+ return deleted
@@ -0,0 +1,283 @@
1
+ """Job registry for brawny.
2
+
3
+ Provides job registration and the @job decorator.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import TYPE_CHECKING, Callable, TypeVar, overload
10
+
11
+ from brawny.logging import get_logger
12
+
13
+
14
+ def _camel_to_snake(name: str) -> str:
15
+ """Convert CamelCase to snake_case."""
16
+ s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
17
+ return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
18
+
19
+
20
+ def _humanize_class_name(name: str) -> str:
21
+ """Convert CamelCase to human-readable."""
22
+ return re.sub('([a-z])([A-Z])', r'\1 \2', name)
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ from brawny.jobs.base import Job
27
+
28
+ T = TypeVar("T", bound="Job")
29
+
30
+ logger = get_logger(__name__)
31
+
32
+
33
+ class JobRegistry:
34
+ """Registry for managing job instances.
35
+
36
+ Jobs can be registered via:
37
+ - @brawny.job decorator
38
+ - Explicit registry.register(job) call
39
+ - Module discovery via config
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ self._jobs: dict[str, Job] = {}
44
+ self._job_classes: dict[str, type[Job]] = {}
45
+
46
+ def register(self, job_or_class: Job | type[Job]) -> Job | type[Job]:
47
+ """Register a job instance or class.
48
+
49
+ Typically called internally by @job decorator. Can also be called directly:
50
+ registry.register(MyJob())
51
+
52
+ Args:
53
+ job_or_class: Job instance or class to register
54
+
55
+ Returns:
56
+ The registered job/class (for decorator usage)
57
+
58
+ Raises:
59
+ ValueError: If duplicate job_id
60
+ """
61
+ if isinstance(job_or_class, type):
62
+ job_class = job_or_class
63
+ job = job_class()
64
+ else:
65
+ job = job_or_class
66
+ job_class = type(job)
67
+
68
+ # Auto-derive job_id and name if not explicitly set
69
+ if not getattr(job, "job_id", None):
70
+ job.job_id = _camel_to_snake(job_class.__name__)
71
+ if not getattr(job, "name", None):
72
+ job.name = _humanize_class_name(job_class.__name__)
73
+
74
+ job_id = job.job_id
75
+
76
+ if job_id in self._jobs:
77
+ existing = self._jobs[job_id]
78
+ raise ValueError(f"Duplicate job_id '{job_id}': already registered by {type(existing).__name__}")
79
+
80
+ self._jobs[job_id] = job
81
+ self._job_classes[job_id] = job_class
82
+ logger.debug("job.registry.registered", job_id=job_id, job_class=job_class.__name__)
83
+
84
+ return job_or_class
85
+
86
+ def unregister(self, job_id: str) -> bool:
87
+ """Unregister a job by ID.
88
+
89
+ Args:
90
+ job_id: Job ID to unregister
91
+
92
+ Returns:
93
+ True if job was unregistered, False if not found
94
+ """
95
+ if job_id in self._jobs:
96
+ del self._jobs[job_id]
97
+ del self._job_classes[job_id]
98
+ logger.debug("job.registry.unregistered", job_id=job_id)
99
+ return True
100
+ return False
101
+
102
+ def get(self, job_id: str) -> Job | None:
103
+ """Get a job by ID.
104
+
105
+ Args:
106
+ job_id: Job ID to retrieve
107
+
108
+ Returns:
109
+ Job instance or None if not found
110
+ """
111
+ return self._jobs.get(job_id)
112
+
113
+ def get_all(self) -> list[Job]:
114
+ """Get all registered jobs ordered by job_id.
115
+
116
+ Returns:
117
+ List of job instances
118
+ """
119
+ return [self._jobs[jid] for jid in sorted(self._jobs.keys())]
120
+
121
+ def list_job_ids(self) -> list[str]:
122
+ """List all registered job IDs.
123
+
124
+ Returns:
125
+ List of job IDs
126
+ """
127
+ return sorted(self._jobs.keys())
128
+
129
+ def __len__(self) -> int:
130
+ """Return number of registered jobs."""
131
+ return len(self._jobs)
132
+
133
+ def __contains__(self, job_id: str) -> bool:
134
+ """Check if a job ID is registered."""
135
+ return job_id in self._jobs
136
+
137
+ def __iter__(self):
138
+ """Iterate over registered jobs in job_id order."""
139
+ return iter(self.get_all())
140
+
141
+ def clear(self) -> None:
142
+ """Clear all registered jobs."""
143
+ self._jobs.clear()
144
+ self._job_classes.clear()
145
+
146
+
147
+ # Global registry instance
148
+ registry = JobRegistry()
149
+
150
+
151
+ def get_registry() -> JobRegistry:
152
+ """Return the global job registry."""
153
+ return registry
154
+
155
+
156
+ @overload
157
+ def job(cls: type[T]) -> type[T]: ...
158
+
159
+
160
+ @overload
161
+ def job(
162
+ cls: None = None,
163
+ *,
164
+ job_id: str | None = None,
165
+ rpc_group: str | None = None,
166
+ read_group: str | None = None,
167
+ broadcast_group: str | None = None,
168
+ signer: str | None = None,
169
+ alert_to: str | list[str] | None = None,
170
+ ) -> Callable[[type[T]], type[T]]: ...
171
+
172
+
173
+ def job(
174
+ cls: type[T] | None = None,
175
+ *,
176
+ job_id: str | None = None,
177
+ rpc_group: str | None = None,
178
+ read_group: str | None = None,
179
+ broadcast_group: str | None = None,
180
+ signer: str | None = None,
181
+ alert_to: str | list[str] | None = None,
182
+ ) -> type[T] | Callable[[type[T]], type[T]]:
183
+ """Register a job class with optional RPC routing configuration.
184
+
185
+ Works with or without parentheses:
186
+ @job # Simple registration (defaults)
187
+ @job() # Same as above
188
+ @job(job_id="my_job") # Custom job ID
189
+ @job(signer="hot1", rpc_group="private") # Full config
190
+ @job(alert_to="dev") # Send alerts to "dev" chat
191
+
192
+ Args:
193
+ cls: The job class (auto-passed when used without parentheses)
194
+ job_id: Job identifier (defaults to snake_case of class name, or cls.job_id if set)
195
+ rpc_group: RPC group for both read and broadcast routing
196
+ read_group: RPC group for read operations (default: resolved at runtime)
197
+ broadcast_group: RPC group for broadcasts (default: resolved at runtime)
198
+ signer: Signer key name from config (required for tx jobs)
199
+ alert_to: Telegram chat name(s) for alerts (overrides config.telegram.default).
200
+ Can be a single name or list of names. Names must be defined in config.
201
+
202
+ Raises:
203
+ TypeError: If decorator is misused (e.g., @job("string"))
204
+ ValueError: If job_id is already registered
205
+ """
206
+ def _validate_str(name: str, value: str) -> None:
207
+ if not isinstance(value, str) or not value.strip():
208
+ raise TypeError(f"{name} must be a non-empty string")
209
+
210
+ def _configure_and_register(job_cls: type[T]) -> type[T]:
211
+ if not isinstance(job_cls, type):
212
+ raise TypeError("@job must decorate a class")
213
+
214
+ # Resolve job_id: explicit param > class attr > derive from name
215
+ if job_id is not None:
216
+ _validate_str("job_id", job_id)
217
+ resolved_job_id = job_id
218
+ else:
219
+ existing = getattr(job_cls, "job_id", None)
220
+ if isinstance(existing, str) and existing.strip():
221
+ resolved_job_id = existing
222
+ else:
223
+ resolved_job_id = _camel_to_snake(job_cls.__name__)
224
+
225
+ # Validate routing config
226
+ if rpc_group is not None and (read_group is not None or broadcast_group is not None):
227
+ raise TypeError("rpc_group cannot be combined with read_group/broadcast_group")
228
+
229
+ if rpc_group is not None:
230
+ _validate_str("rpc_group", rpc_group)
231
+ resolved_read_group = rpc_group
232
+ resolved_broadcast_group = rpc_group
233
+ else:
234
+ resolved_read_group = read_group
235
+ resolved_broadcast_group = broadcast_group
236
+ if resolved_read_group is not None:
237
+ _validate_str("read_group", resolved_read_group)
238
+ if resolved_broadcast_group is not None:
239
+ _validate_str("broadcast_group", resolved_broadcast_group)
240
+
241
+ if signer is not None:
242
+ _validate_str("signer", signer)
243
+
244
+ # Check for duplicate registration
245
+ if resolved_job_id in registry._jobs:
246
+ raise ValueError(f"Job '{resolved_job_id}' already registered")
247
+
248
+ # Normalize alert_to to list | None (dedupe while preserving order)
249
+ # Empty list [] or whitespace normalizes to None, not []
250
+ resolved_alert_to: list[str] | None = None
251
+ if alert_to is not None:
252
+ if isinstance(alert_to, str):
253
+ resolved_alert_to = [alert_to.strip()] if alert_to.strip() else None
254
+ else:
255
+ # Dedupe while preserving order
256
+ seen: set[str] = set()
257
+ deduped: list[str] = []
258
+ for n in alert_to:
259
+ if n and n.strip():
260
+ stripped = n.strip()
261
+ if stripped not in seen:
262
+ seen.add(stripped)
263
+ deduped.append(stripped)
264
+ resolved_alert_to = deduped if deduped else None
265
+
266
+ # Attach config to class
267
+ job_cls.job_id = resolved_job_id # type: ignore[attr-defined]
268
+ job_cls._read_group = resolved_read_group # type: ignore[attr-defined]
269
+ job_cls._broadcast_group = resolved_broadcast_group # type: ignore[attr-defined]
270
+ job_cls._signer_name = signer # type: ignore[attr-defined]
271
+ job_cls._alert_to = resolved_alert_to # type: ignore[attr-defined]
272
+
273
+ # Auto-derive name if not set
274
+ if not getattr(job_cls, "name", None):
275
+ job_cls.name = _humanize_class_name(job_cls.__name__) # type: ignore[attr-defined]
276
+
277
+ registry.register(job_cls)
278
+ return job_cls
279
+
280
+ # Detect usage: @job vs @job(...)
281
+ if cls is not None:
282
+ return _configure_and_register(cls)
283
+ return _configure_and_register