fleet-framework 0.1.0__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.
- fleet/__init__.py +1 -0
- fleet/cli.py +290 -0
- fleet/core/__init__.py +69 -0
- fleet/core/automation.py +125 -0
- fleet/core/backend.py +736 -0
- fleet/core/config.py +38 -0
- fleet/core/context.py +102 -0
- fleet/core/contract.py +87 -0
- fleet/core/country_presets.py +50 -0
- fleet/core/events.py +55 -0
- fleet/core/logging.py +97 -0
- fleet/core/memory_backend.py +492 -0
- fleet/core/metrics.py +61 -0
- fleet/core/otel.py +97 -0
- fleet/core/primitives.py +310 -0
- fleet/core/protocol.py +171 -0
- fleet/core/proxy.py +166 -0
- fleet/core/reconcile.py +75 -0
- fleet/core/sqlite_backend.py +1117 -0
- fleet/core/store.py +104 -0
- fleet/master/__init__.py +3 -0
- fleet/master/api.py +324 -0
- fleet/master/app.py +105 -0
- fleet/master/auth.py +132 -0
- fleet/master/broadcaster.py +37 -0
- fleet/master/dashboard/__init__.py +4 -0
- fleet/master/dashboard/router.py +36 -0
- fleet/master/dashboard/static/style.css +97 -0
- fleet/master/dashboard/templates/index.html +372 -0
- fleet/master/metrics_route.py +141 -0
- fleet/master/ratelimit.py +55 -0
- fleet/master/ws_router.py +142 -0
- fleet/worker/__init__.py +3 -0
- fleet/worker/agent.py +173 -0
- fleet/worker/reconcile_loop.py +246 -0
- fleet/worker/slot_runner.py +256 -0
- fleet/worker/ws_client.py +164 -0
- fleet_browser/__init__.py +21 -0
- fleet_browser/browser.py +277 -0
- fleet_browser/cert.py +68 -0
- fleet_browser/fingerprint.py +327 -0
- fleet_browser/humanizer.py +157 -0
- fleet_browser/pool.py +241 -0
- fleet_browser/proxy_extension.py +122 -0
- fleet_browser/solver.py +51 -0
- fleet_browser/stealth.py +80 -0
- fleet_cloudflare/__init__.py +22 -0
- fleet_cloudflare/bypasser.py +168 -0
- fleet_cloudflare/harvest.py +266 -0
- fleet_cloudflare/replay.py +82 -0
- fleet_cloudflare/solver.py +28 -0
- fleet_content/__init__.py +24 -0
- fleet_content/automation.py +43 -0
- fleet_content/contracts.py +76 -0
- fleet_detect/__init__.py +26 -0
- fleet_detect/contracts.py +67 -0
- fleet_detect/detect.py +126 -0
- fleet_framework-0.1.0.dist-info/METADATA +160 -0
- fleet_framework-0.1.0.dist-info/RECORD +85 -0
- fleet_framework-0.1.0.dist-info/WHEEL +5 -0
- fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
- fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
- fleet_headers/__init__.py +28 -0
- fleet_headers/profiles.py +131 -0
- fleet_jobs/__init__.py +28 -0
- fleet_jobs/automation.py +34 -0
- fleet_jobs/contracts.py +143 -0
- fleet_marketplace/__init__.py +33 -0
- fleet_marketplace/automation.py +32 -0
- fleet_marketplace/contracts.py +151 -0
- fleet_news/__init__.py +21 -0
- fleet_news/automation.py +51 -0
- fleet_news/contracts.py +59 -0
- fleet_place/__init__.py +33 -0
- fleet_place/automation.py +37 -0
- fleet_place/contracts.py +156 -0
- fleet_provider_dataimpulse/__init__.py +82 -0
- fleet_provider_evomi/__init__.py +76 -0
- fleet_serp/__init__.py +30 -0
- fleet_serp/automation.py +47 -0
- fleet_serp/contracts.py +100 -0
- fleet_social/__init__.py +34 -0
- fleet_social/automation.py +44 -0
- fleet_social/contracts.py +172 -0
fleet/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# namespace marker
|
fleet/cli.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from fleet.core.logging import configure_logging
|
|
13
|
+
|
|
14
|
+
configure_logging()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
def cli() -> None:
|
|
19
|
+
# fleet — master + worker CLI.
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@cli.command()
|
|
24
|
+
@click.option("--host", default="0.0.0.0", show_default=True)
|
|
25
|
+
@click.option("--port", default=8000, show_default=True)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--backend-url",
|
|
28
|
+
envvar="FLEET_BACKEND_URL",
|
|
29
|
+
default=None,
|
|
30
|
+
show_default=True,
|
|
31
|
+
help="Backend URL: redis://..., memory://, or any registered scheme. "
|
|
32
|
+
"Defaults to $FLEET_BACKEND_URL, then $REDIS_URL, then redis://localhost:6379/0.",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--redis-url",
|
|
36
|
+
envvar="REDIS_URL",
|
|
37
|
+
default=None,
|
|
38
|
+
show_default=True,
|
|
39
|
+
help="(Legacy) Use --backend-url instead.",
|
|
40
|
+
)
|
|
41
|
+
def master(host: str, port: int, backend_url: str | None, redis_url: str | None) -> None:
|
|
42
|
+
"""Start the master process. Binds REST + WS + dashboard."""
|
|
43
|
+
from fleet.master import create_app
|
|
44
|
+
|
|
45
|
+
url = backend_url or redis_url or "redis://localhost:6379/0"
|
|
46
|
+
if redis_url:
|
|
47
|
+
os.environ.setdefault("REDIS_URL", redis_url)
|
|
48
|
+
os.environ.setdefault("FLEET_BACKEND_URL", url)
|
|
49
|
+
app = create_app(url)
|
|
50
|
+
uvicorn.run(app, host=host, port=port, log_level=os.environ.get("LOG_LEVEL", "info").lower())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command()
|
|
54
|
+
@click.option("--type", "automation_type", required=True, help="automation type name (entry-point key)")
|
|
55
|
+
@click.option("--id", "worker_id", default=None, help="worker id (defaults to hostname)")
|
|
56
|
+
@click.option("--master-url", envvar="MASTER_URL", required=True)
|
|
57
|
+
@click.option("--worker-token", envvar="FLEET_WORKER_TOKEN", required=True)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--backend-url",
|
|
60
|
+
envvar="FLEET_BACKEND_URL",
|
|
61
|
+
default=None,
|
|
62
|
+
help="Backend URL; falls back to --redis-url then redis://localhost:6379/0.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--redis-url",
|
|
66
|
+
envvar="REDIS_URL",
|
|
67
|
+
default=None,
|
|
68
|
+
help="(Legacy) Use --backend-url instead.",
|
|
69
|
+
)
|
|
70
|
+
def worker(
|
|
71
|
+
automation_type: str,
|
|
72
|
+
worker_id: str | None,
|
|
73
|
+
master_url: str,
|
|
74
|
+
worker_token: str,
|
|
75
|
+
backend_url: str | None,
|
|
76
|
+
redis_url: str | None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Start a worker process pinned to one automation type."""
|
|
79
|
+
from fleet.worker import Agent
|
|
80
|
+
|
|
81
|
+
url = backend_url or redis_url or "redis://localhost:6379/0"
|
|
82
|
+
wid = worker_id or socket.gethostname()
|
|
83
|
+
agent = Agent(
|
|
84
|
+
automation_type=automation_type,
|
|
85
|
+
worker_id=wid,
|
|
86
|
+
master_url=master_url,
|
|
87
|
+
worker_token=worker_token,
|
|
88
|
+
redis_url=url,
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
asyncio.run(agent.run())
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _list_plugins() -> None:
|
|
97
|
+
from fleet.core.automation import load_entry_points
|
|
98
|
+
from fleet.core.proxy import load_provider_entry_points
|
|
99
|
+
|
|
100
|
+
reg = load_entry_points()
|
|
101
|
+
if reg:
|
|
102
|
+
click.echo("automations:")
|
|
103
|
+
for name, cls in reg.items():
|
|
104
|
+
kind = cls.__mro__[1].__name__
|
|
105
|
+
click.echo(f" {name:30} {cls.__module__}.{cls.__name__} ({kind})")
|
|
106
|
+
else:
|
|
107
|
+
click.echo("automations: (none installed)")
|
|
108
|
+
providers = load_provider_entry_points()
|
|
109
|
+
if providers:
|
|
110
|
+
click.echo("proxy providers:")
|
|
111
|
+
for name in providers:
|
|
112
|
+
click.echo(f" {name}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@cli.command()
|
|
116
|
+
def plugins() -> None:
|
|
117
|
+
"""List installed automations + proxy providers discovered via entry-points."""
|
|
118
|
+
_list_plugins()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@cli.command(name="info")
|
|
122
|
+
def info_alias() -> None:
|
|
123
|
+
"""Alias of `plugins`. Kept for backwards compatibility."""
|
|
124
|
+
_list_plugins()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@cli.command()
|
|
128
|
+
@click.argument("automation_type")
|
|
129
|
+
@click.option("--master-url", envvar="MASTER_URL", required=True)
|
|
130
|
+
@click.option("--admin-token", envvar="FLEET_ADMIN_TOKEN", required=True)
|
|
131
|
+
@click.option("--n", default=10, show_default=True, help="entries per pop")
|
|
132
|
+
@click.option("--interval", default=1.0, show_default=True, help="seconds between polls")
|
|
133
|
+
@click.option("--peek", is_flag=True, help="non-destructive read instead of pop")
|
|
134
|
+
def tail(
|
|
135
|
+
automation_type: str,
|
|
136
|
+
master_url: str,
|
|
137
|
+
admin_token: str,
|
|
138
|
+
n: int,
|
|
139
|
+
interval: float,
|
|
140
|
+
peek: bool,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Stream the output of an automation type to stdout, one JSON object per line."""
|
|
143
|
+
import httpx
|
|
144
|
+
|
|
145
|
+
headers = {"Authorization": f"Bearer {admin_token}"}
|
|
146
|
+
op = "peek" if peek else "pop"
|
|
147
|
+
base = master_url.rstrip("/")
|
|
148
|
+
try:
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
if peek:
|
|
152
|
+
url = f"{base}/api/v1/automations/{automation_type}/output/peek?n={n}"
|
|
153
|
+
r = httpx.get(url, headers=headers, timeout=10)
|
|
154
|
+
else:
|
|
155
|
+
url = f"{base}/api/v1/automations/{automation_type}/output/pop"
|
|
156
|
+
r = httpx.post(url, headers=headers, json={"n": n}, timeout=10)
|
|
157
|
+
r.raise_for_status()
|
|
158
|
+
for env in r.json():
|
|
159
|
+
click.echo(json.dumps(env, default=str))
|
|
160
|
+
except Exception as e:
|
|
161
|
+
click.echo(f"# {op} failed: {e}", err=True)
|
|
162
|
+
import time as _t
|
|
163
|
+
_t.sleep(max(0.1, interval))
|
|
164
|
+
except KeyboardInterrupt:
|
|
165
|
+
sys.exit(0)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@cli.command()
|
|
169
|
+
@click.argument("automation_type")
|
|
170
|
+
@click.argument("payload") # JSON string or @path
|
|
171
|
+
@click.option("--master-url", envvar="MASTER_URL", required=True)
|
|
172
|
+
@click.option("--admin-token", envvar="FLEET_ADMIN_TOKEN", required=True)
|
|
173
|
+
@click.option("--task-id", default=None, help="override the framework's UUID")
|
|
174
|
+
@click.option("--max-attempts", default=None, type=int, help="override per-task retry budget")
|
|
175
|
+
def submit(
|
|
176
|
+
automation_type: str,
|
|
177
|
+
payload: str,
|
|
178
|
+
master_url: str,
|
|
179
|
+
admin_token: str,
|
|
180
|
+
task_id: str | None,
|
|
181
|
+
max_attempts: int | None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Submit a single task to a BatchAutomation.
|
|
184
|
+
|
|
185
|
+
PAYLOAD is a JSON string or @path/to/file.json. Validated against the
|
|
186
|
+
automation's TaskPayload schema server-side.
|
|
187
|
+
"""
|
|
188
|
+
import httpx
|
|
189
|
+
|
|
190
|
+
body_text = payload
|
|
191
|
+
if payload.startswith("@"):
|
|
192
|
+
body_text = open(payload[1:]).read()
|
|
193
|
+
try:
|
|
194
|
+
parsed = json.loads(body_text)
|
|
195
|
+
except json.JSONDecodeError as e:
|
|
196
|
+
click.echo(f"payload is not valid JSON: {e}", err=True)
|
|
197
|
+
sys.exit(2)
|
|
198
|
+
req: dict = {"payload": parsed}
|
|
199
|
+
if task_id:
|
|
200
|
+
req["task_id"] = task_id
|
|
201
|
+
if max_attempts is not None:
|
|
202
|
+
req["max_attempts"] = max_attempts
|
|
203
|
+
url = master_url.rstrip("/") + f"/api/v1/automations/{automation_type}/tasks"
|
|
204
|
+
r = httpx.post(url, headers={"Authorization": f"Bearer {admin_token}"}, json=req, timeout=10)
|
|
205
|
+
if r.status_code >= 400:
|
|
206
|
+
click.echo(f"submit failed ({r.status_code}): {r.text}", err=True)
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
click.echo(json.dumps(r.json()))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@cli.command()
|
|
212
|
+
@click.option("--master-url", envvar="MASTER_URL", default=None)
|
|
213
|
+
@click.option("--admin-token", envvar="FLEET_ADMIN_TOKEN", default=None)
|
|
214
|
+
@click.option("--worker-token", envvar="FLEET_WORKER_TOKEN", default=None)
|
|
215
|
+
@click.option("--backend-url", envvar="FLEET_BACKEND_URL", default=None)
|
|
216
|
+
def doctor(
|
|
217
|
+
master_url: str | None,
|
|
218
|
+
admin_token: str | None,
|
|
219
|
+
worker_token: str | None,
|
|
220
|
+
backend_url: str | None,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Pre-flight sanity checks: env tokens, plugins, backend, master reachable."""
|
|
223
|
+
asyncio.run(_doctor(master_url, admin_token, worker_token, backend_url))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _doctor(master_url, admin_token, worker_token, backend_url) -> None:
|
|
227
|
+
import httpx
|
|
228
|
+
|
|
229
|
+
ok = True
|
|
230
|
+
|
|
231
|
+
def _check(label: str, condition: bool, detail: str = "") -> None:
|
|
232
|
+
nonlocal ok
|
|
233
|
+
mark = "✓" if condition else "✗"
|
|
234
|
+
ok = ok and condition
|
|
235
|
+
click.echo(f"{mark} {label}" + (f" — {detail}" if detail else ""))
|
|
236
|
+
|
|
237
|
+
_check("FLEET_ADMIN_TOKEN set", bool(admin_token))
|
|
238
|
+
_check("FLEET_WORKER_TOKEN set", bool(worker_token))
|
|
239
|
+
|
|
240
|
+
from fleet.core.automation import load_entry_points
|
|
241
|
+
reg = load_entry_points()
|
|
242
|
+
_check("automations discovered", bool(reg), f"{len(reg)} found")
|
|
243
|
+
for name in reg:
|
|
244
|
+
click.echo(f" {name}")
|
|
245
|
+
|
|
246
|
+
if backend_url:
|
|
247
|
+
try:
|
|
248
|
+
from fleet.core.backend import RedisBackend, backend_from_url
|
|
249
|
+
be = backend_from_url(backend_url)
|
|
250
|
+
_check("backend constructs", True, type(be).__name__)
|
|
251
|
+
if isinstance(be, RedisBackend):
|
|
252
|
+
try:
|
|
253
|
+
pong = await be.r.ping()
|
|
254
|
+
_check("redis ping", bool(pong))
|
|
255
|
+
info = await be.r.info("persistence")
|
|
256
|
+
aof = info.get(b"aof_enabled", info.get("aof_enabled"))
|
|
257
|
+
aof_on = str(aof).strip() in ("1", "True", "true")
|
|
258
|
+
_check(
|
|
259
|
+
"redis AOF on", aof_on,
|
|
260
|
+
"" if aof_on else "data lives only in RAM; configs/pools die on restart",
|
|
261
|
+
)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
_check("redis reachable", False, str(e))
|
|
264
|
+
finally:
|
|
265
|
+
await be.aclose()
|
|
266
|
+
except Exception as e:
|
|
267
|
+
_check("backend constructs", False, str(e))
|
|
268
|
+
else:
|
|
269
|
+
click.echo("· backend-url not set (skipping backend checks)")
|
|
270
|
+
|
|
271
|
+
if master_url and admin_token:
|
|
272
|
+
try:
|
|
273
|
+
async with httpx.AsyncClient(timeout=3) as c:
|
|
274
|
+
r = await c.get(master_url.rstrip("/") + "/healthz")
|
|
275
|
+
_check("master /healthz reachable", r.status_code == 200, f"HTTP {r.status_code}")
|
|
276
|
+
r = await c.get(
|
|
277
|
+
master_url.rstrip("/") + "/api/v1/automations",
|
|
278
|
+
headers={"Authorization": f"Bearer {admin_token}"},
|
|
279
|
+
)
|
|
280
|
+
_check("admin token accepted", r.status_code == 200, f"HTTP {r.status_code}")
|
|
281
|
+
except Exception as e:
|
|
282
|
+
_check("master reachable", False, str(e))
|
|
283
|
+
else:
|
|
284
|
+
click.echo("· master-url or admin-token not set (skipping master checks)")
|
|
285
|
+
|
|
286
|
+
sys.exit(0 if ok else 1)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
cli()
|
fleet/core/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from fleet.core.automation import (
|
|
2
|
+
BaseAutomation,
|
|
3
|
+
BatchAutomation,
|
|
4
|
+
ContinuousAutomation,
|
|
5
|
+
Task,
|
|
6
|
+
catalog_doc,
|
|
7
|
+
get_registry,
|
|
8
|
+
load_entry_points,
|
|
9
|
+
register,
|
|
10
|
+
)
|
|
11
|
+
from fleet.core.backend import Backend, RedisBackend
|
|
12
|
+
from fleet.core.config import BaseConfig
|
|
13
|
+
from fleet.core.context import Context
|
|
14
|
+
from fleet.core.contract import Pool, Queue, Stream
|
|
15
|
+
from fleet.core.country_presets import known_presets, resolve_countries
|
|
16
|
+
from fleet.core.primitives import (
|
|
17
|
+
KV,
|
|
18
|
+
Counter,
|
|
19
|
+
Lock,
|
|
20
|
+
PoolFilter,
|
|
21
|
+
PoolHandle,
|
|
22
|
+
PoolItem,
|
|
23
|
+
QueueHandle,
|
|
24
|
+
StreamReader,
|
|
25
|
+
)
|
|
26
|
+
from fleet.core.proxy import (
|
|
27
|
+
ProxyHandle,
|
|
28
|
+
ProxyProvider,
|
|
29
|
+
ProxySession,
|
|
30
|
+
build_provider,
|
|
31
|
+
get_provider_registry,
|
|
32
|
+
load_provider_entry_points,
|
|
33
|
+
register_provider,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Backend",
|
|
38
|
+
"BaseAutomation",
|
|
39
|
+
"BaseConfig",
|
|
40
|
+
"BatchAutomation",
|
|
41
|
+
"ContinuousAutomation",
|
|
42
|
+
"Context",
|
|
43
|
+
"Counter",
|
|
44
|
+
"KV",
|
|
45
|
+
"Lock",
|
|
46
|
+
"Pool",
|
|
47
|
+
"PoolFilter",
|
|
48
|
+
"PoolHandle",
|
|
49
|
+
"PoolItem",
|
|
50
|
+
"ProxyHandle",
|
|
51
|
+
"ProxyProvider",
|
|
52
|
+
"ProxySession",
|
|
53
|
+
"Queue",
|
|
54
|
+
"QueueHandle",
|
|
55
|
+
"RedisBackend",
|
|
56
|
+
"Stream",
|
|
57
|
+
"StreamReader",
|
|
58
|
+
"Task",
|
|
59
|
+
"build_provider",
|
|
60
|
+
"catalog_doc",
|
|
61
|
+
"get_provider_registry",
|
|
62
|
+
"get_registry",
|
|
63
|
+
"known_presets",
|
|
64
|
+
"load_entry_points",
|
|
65
|
+
"load_provider_entry_points",
|
|
66
|
+
"register",
|
|
67
|
+
"register_provider",
|
|
68
|
+
"resolve_countries",
|
|
69
|
+
]
|
fleet/core/automation.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata as md
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, ClassVar, Generic, Optional, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from fleet.core.config import BaseConfig
|
|
11
|
+
from fleet.core.context import Context
|
|
12
|
+
from fleet.core.contract import Pool, Queue, Stream
|
|
13
|
+
|
|
14
|
+
C = TypeVar("C", bound=BaseConfig)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_REGISTRY: dict[str, type["BaseAutomation"]] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Task:
|
|
23
|
+
id: str
|
|
24
|
+
payload: BaseModel # typed if the automation declared TaskPayload, else a fallback model
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseAutomation(Generic[C]):
|
|
28
|
+
# subclass + decorate with @register("name"). do not instantiate directly.
|
|
29
|
+
Config: ClassVar[type[BaseConfig]] = BaseConfig
|
|
30
|
+
|
|
31
|
+
# what other plugins (and the master catalog) need to know about this
|
|
32
|
+
# automation. None means this automation doesn't have that kind of resource.
|
|
33
|
+
Output: ClassVar[Optional[type[BaseModel]]] = None # stream payload type (one stream per automation, by convention)
|
|
34
|
+
TaskPayload: ClassVar[Optional[type[BaseModel]]] = None # BatchAutomation task payload
|
|
35
|
+
Pools: ClassVar[dict[str, Pool]] = {} # pools this automation owns
|
|
36
|
+
|
|
37
|
+
automation_type: ClassVar[str] = ""
|
|
38
|
+
|
|
39
|
+
async def cleanup(self, ctx: Context[C]) -> None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def own_stream(cls) -> Optional[Stream]:
|
|
44
|
+
if cls.Output is None:
|
|
45
|
+
return None
|
|
46
|
+
return Stream(cls.automation_type, payload=cls.Output)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def own_queue(cls) -> Optional[Queue]:
|
|
50
|
+
if cls.TaskPayload is None:
|
|
51
|
+
return None
|
|
52
|
+
return Queue(cls.automation_type, payload=cls.TaskPayload)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ContinuousAutomation(BaseAutomation[C]):
|
|
56
|
+
async def run_slot(self, ctx: Context[C]) -> None:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BatchAutomation(BaseAutomation[C]):
|
|
61
|
+
async def run_one(self, task: Task, ctx: Context[C]) -> None:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def register(name: str):
|
|
66
|
+
def _wrap(cls: type[BaseAutomation]) -> type[BaseAutomation]:
|
|
67
|
+
if not issubclass(cls, BaseAutomation):
|
|
68
|
+
raise TypeError(f"{cls.__name__} must subclass BaseAutomation")
|
|
69
|
+
# BatchAutomation must declare TaskPayload; ContinuousAutomation should declare Output.
|
|
70
|
+
if issubclass(cls, BatchAutomation) and cls.TaskPayload is None:
|
|
71
|
+
raise TypeError(
|
|
72
|
+
f"{cls.__name__} is a BatchAutomation but did not declare 'TaskPayload'"
|
|
73
|
+
)
|
|
74
|
+
cls.automation_type = name
|
|
75
|
+
if name in _REGISTRY and _REGISTRY[name] is not cls:
|
|
76
|
+
raise ValueError(f"automation '{name}' already registered: {_REGISTRY[name]}")
|
|
77
|
+
_REGISTRY[name] = cls
|
|
78
|
+
logger.debug("registered automation '%s' -> %s", name, cls.__name__)
|
|
79
|
+
return cls
|
|
80
|
+
|
|
81
|
+
return _wrap
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_registry() -> dict[str, type[BaseAutomation]]:
|
|
85
|
+
return dict(_REGISTRY)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_entry_points(group: str = "fleet.automations") -> dict[str, type[BaseAutomation]]:
|
|
89
|
+
try:
|
|
90
|
+
eps = md.entry_points(group=group)
|
|
91
|
+
except TypeError:
|
|
92
|
+
eps = md.entry_points().get(group, []) # type: ignore[assignment]
|
|
93
|
+
for ep in eps:
|
|
94
|
+
try:
|
|
95
|
+
ep.load()
|
|
96
|
+
except Exception:
|
|
97
|
+
logger.exception("failed to load entry-point %s", ep.name)
|
|
98
|
+
return get_registry()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def catalog_doc(cls: type[BaseAutomation]) -> dict[str, Any]:
|
|
102
|
+
# the master serves this. consumers don't need it for typing (they import
|
|
103
|
+
# the same Pool/Queue/Stream specs from the shared contracts package),
|
|
104
|
+
# but it's useful for the dashboard and for write-side validation.
|
|
105
|
+
out: dict[str, Any] = {
|
|
106
|
+
"type": cls.automation_type,
|
|
107
|
+
"kind": cls.__mro__[1].__name__,
|
|
108
|
+
"config": cls.Config.model_json_schema(),
|
|
109
|
+
}
|
|
110
|
+
if cls.Output is not None:
|
|
111
|
+
out["stream"] = {
|
|
112
|
+
"name": cls.automation_type,
|
|
113
|
+
"payload": cls.Output.model_json_schema(),
|
|
114
|
+
}
|
|
115
|
+
if cls.TaskPayload is not None:
|
|
116
|
+
out["queue"] = {
|
|
117
|
+
"name": cls.automation_type,
|
|
118
|
+
"payload": cls.TaskPayload.model_json_schema(),
|
|
119
|
+
}
|
|
120
|
+
if cls.Pools:
|
|
121
|
+
out["pools"] = {
|
|
122
|
+
n: {"payload": p.payload.model_json_schema(), "tags": p.tags.model_json_schema()}
|
|
123
|
+
for n, p in cls.Pools.items()
|
|
124
|
+
}
|
|
125
|
+
return out
|