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.
Files changed (85) hide show
  1. fleet/__init__.py +1 -0
  2. fleet/cli.py +290 -0
  3. fleet/core/__init__.py +69 -0
  4. fleet/core/automation.py +125 -0
  5. fleet/core/backend.py +736 -0
  6. fleet/core/config.py +38 -0
  7. fleet/core/context.py +102 -0
  8. fleet/core/contract.py +87 -0
  9. fleet/core/country_presets.py +50 -0
  10. fleet/core/events.py +55 -0
  11. fleet/core/logging.py +97 -0
  12. fleet/core/memory_backend.py +492 -0
  13. fleet/core/metrics.py +61 -0
  14. fleet/core/otel.py +97 -0
  15. fleet/core/primitives.py +310 -0
  16. fleet/core/protocol.py +171 -0
  17. fleet/core/proxy.py +166 -0
  18. fleet/core/reconcile.py +75 -0
  19. fleet/core/sqlite_backend.py +1117 -0
  20. fleet/core/store.py +104 -0
  21. fleet/master/__init__.py +3 -0
  22. fleet/master/api.py +324 -0
  23. fleet/master/app.py +105 -0
  24. fleet/master/auth.py +132 -0
  25. fleet/master/broadcaster.py +37 -0
  26. fleet/master/dashboard/__init__.py +4 -0
  27. fleet/master/dashboard/router.py +36 -0
  28. fleet/master/dashboard/static/style.css +97 -0
  29. fleet/master/dashboard/templates/index.html +372 -0
  30. fleet/master/metrics_route.py +141 -0
  31. fleet/master/ratelimit.py +55 -0
  32. fleet/master/ws_router.py +142 -0
  33. fleet/worker/__init__.py +3 -0
  34. fleet/worker/agent.py +173 -0
  35. fleet/worker/reconcile_loop.py +246 -0
  36. fleet/worker/slot_runner.py +256 -0
  37. fleet/worker/ws_client.py +164 -0
  38. fleet_browser/__init__.py +21 -0
  39. fleet_browser/browser.py +277 -0
  40. fleet_browser/cert.py +68 -0
  41. fleet_browser/fingerprint.py +327 -0
  42. fleet_browser/humanizer.py +157 -0
  43. fleet_browser/pool.py +241 -0
  44. fleet_browser/proxy_extension.py +122 -0
  45. fleet_browser/solver.py +51 -0
  46. fleet_browser/stealth.py +80 -0
  47. fleet_cloudflare/__init__.py +22 -0
  48. fleet_cloudflare/bypasser.py +168 -0
  49. fleet_cloudflare/harvest.py +266 -0
  50. fleet_cloudflare/replay.py +82 -0
  51. fleet_cloudflare/solver.py +28 -0
  52. fleet_content/__init__.py +24 -0
  53. fleet_content/automation.py +43 -0
  54. fleet_content/contracts.py +76 -0
  55. fleet_detect/__init__.py +26 -0
  56. fleet_detect/contracts.py +67 -0
  57. fleet_detect/detect.py +126 -0
  58. fleet_framework-0.1.0.dist-info/METADATA +160 -0
  59. fleet_framework-0.1.0.dist-info/RECORD +85 -0
  60. fleet_framework-0.1.0.dist-info/WHEEL +5 -0
  61. fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
  62. fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
  63. fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
  64. fleet_headers/__init__.py +28 -0
  65. fleet_headers/profiles.py +131 -0
  66. fleet_jobs/__init__.py +28 -0
  67. fleet_jobs/automation.py +34 -0
  68. fleet_jobs/contracts.py +143 -0
  69. fleet_marketplace/__init__.py +33 -0
  70. fleet_marketplace/automation.py +32 -0
  71. fleet_marketplace/contracts.py +151 -0
  72. fleet_news/__init__.py +21 -0
  73. fleet_news/automation.py +51 -0
  74. fleet_news/contracts.py +59 -0
  75. fleet_place/__init__.py +33 -0
  76. fleet_place/automation.py +37 -0
  77. fleet_place/contracts.py +156 -0
  78. fleet_provider_dataimpulse/__init__.py +82 -0
  79. fleet_provider_evomi/__init__.py +76 -0
  80. fleet_serp/__init__.py +30 -0
  81. fleet_serp/automation.py +47 -0
  82. fleet_serp/contracts.py +100 -0
  83. fleet_social/__init__.py +34 -0
  84. fleet_social/automation.py +44 -0
  85. 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
+ ]
@@ -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