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
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
|
brawny/jobs/registry.py
ADDED
|
@@ -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
|