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/core/config.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class BaseConfig(BaseModel):
9
+ # generic fields every automation has. plugin subclasses add domain knobs.
10
+ model_config = ConfigDict(extra="forbid")
11
+
12
+ enabled: bool = False
13
+ slots: int = Field(default=0, ge=0, le=1024)
14
+ recycle_seconds: int = Field(default=0, ge=0, le=86400)
15
+
16
+ max_attempts: int = Field(default=3, ge=1, le=100)
17
+ """Batch automations only: how many times a task is retried before the DLQ."""
18
+
19
+ visibility_seconds: int = Field(default=300, ge=10, le=86400)
20
+ """Batch automations only: reservation TTL on dequeued tasks. If the slot
21
+ crashes mid-run_one, the master sweeper requeues after this window."""
22
+
23
+ # simple-case proxy. wired into the built-in `static` provider when no
24
+ # provider is named. mutually compatible with proxy_provider/_config.
25
+ proxy_url: str | None = None
26
+
27
+ # provider-based proxy. set both fields together. when set, takes
28
+ # precedence over proxy_url.
29
+ proxy_provider: str | None = None
30
+ proxy_provider_config: dict[str, Any] | None = None
31
+
32
+ @classmethod
33
+ def restart_fields(cls) -> frozenset[str]:
34
+ return frozenset({"proxy_url", "proxy_provider", "proxy_provider_config"})
35
+
36
+ @classmethod
37
+ def reboot_fields(cls) -> frozenset[str]:
38
+ return frozenset()
fleet/core/context.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar
7
+
8
+ from pydantic import BaseModel
9
+
10
+ if TYPE_CHECKING:
11
+ from fleet.core.backend import Backend
12
+ from fleet.core.config import BaseConfig
13
+ from fleet.core.contract import Pool, Queue, Stream
14
+ from fleet.core.events import EventBus
15
+ from fleet.core.metrics import SlotMetrics
16
+ from fleet.core.primitives import KV, Counter, Lock, PoolHandle, StreamReader
17
+ from fleet.core.proxy import ProxyHandle
18
+
19
+ C = TypeVar("C", bound="BaseConfig")
20
+ P = TypeVar("P", bound=BaseModel)
21
+ T = TypeVar("T", bound=BaseModel)
22
+
23
+
24
+ @dataclass
25
+ class Context(Generic[C]):
26
+ # passed into every slot/task callback. owns the lifecycle of one slot.
27
+ automation_type: str
28
+ worker_id: str
29
+ slot_id: int
30
+ config: C
31
+ shutdown: asyncio.Event
32
+ logger: logging.Logger
33
+ _emit_raw: Any # async (payload_dict) -> None — pushes onto own stream via master
34
+ _backend: "Backend"
35
+ _events: "EventBus"
36
+ _metrics: "SlotMetrics"
37
+ _automation_cls: Any = None # type[BaseAutomation], set by SlotRunner
38
+ _proxy: Optional["ProxyHandle"] = None
39
+ proxy_url: Optional[str] = None
40
+ extra: dict[str, Any] = field(default_factory=dict)
41
+
42
+ # ---- proxy ----
43
+ @property
44
+ def proxy(self) -> "ProxyHandle":
45
+ from fleet.core.proxy import ProxyHandle
46
+ if self._proxy is None:
47
+ self._proxy = ProxyHandle(provider=None, slot_id=self.slot_id)
48
+ return self._proxy
49
+
50
+ # ---- own output stream ----
51
+ async def emit(self, payload: BaseModel) -> None:
52
+ # the automation declared `Output = SomePydantic`. validate at the boundary.
53
+ cls = self._automation_cls
54
+ if cls is not None and cls.Output is not None and not isinstance(payload, cls.Output):
55
+ raise TypeError(
56
+ f"{self.automation_type}: emit() expected {cls.Output.__name__}, "
57
+ f"got {type(payload).__name__}"
58
+ )
59
+ if hasattr(payload, "model_dump"):
60
+ await self._emit_raw(payload.model_dump(mode="json"))
61
+ else:
62
+ raise TypeError("emit() requires a Pydantic model")
63
+
64
+ # ---- read another automation's stream ----
65
+ def stream(self, spec: "Stream[P]") -> "StreamReader[P]":
66
+ from fleet.core.primitives import StreamReader
67
+ return StreamReader(spec, self._backend)
68
+
69
+ # ---- pools ----
70
+ def pool(self, spec: "Pool[P, T]") -> "PoolHandle[P, T]":
71
+ from fleet.core.primitives import PoolHandle
72
+ return PoolHandle(spec, self._backend)
73
+
74
+ # ---- submit task to another automation ----
75
+ async def submit(self, spec: "Queue[P]", payload: P, *, task_id: Optional[str] = None) -> str:
76
+ from fleet.core.primitives import QueueHandle
77
+ return await QueueHandle(spec, self._backend).push(payload, task_id=task_id)
78
+
79
+ # ---- kv ----
80
+ def kv(self, namespace: str) -> "KV":
81
+ from fleet.core.primitives import KV
82
+ return KV(self._backend, namespace)
83
+
84
+ # ---- lock ----
85
+ def lock(self, name: str, *, hold_seconds: int = 30, wait_seconds: float = 5.0) -> "Lock":
86
+ from fleet.core.primitives import Lock
87
+ return Lock(self._backend, name, hold_seconds, wait_seconds)
88
+
89
+ # ---- counter ----
90
+ def counter(self, name: str) -> "Counter":
91
+ from fleet.core.primitives import Counter
92
+ return Counter(self._backend, name)
93
+
94
+ # ---- event bus ----
95
+ @property
96
+ def events(self) -> "EventBus":
97
+ return self._events
98
+
99
+ # ---- metrics ----
100
+ @property
101
+ def metrics(self) -> "SlotMetrics":
102
+ return self._metrics
fleet/core/contract.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ from pydantic import BaseModel, TypeAdapter
7
+
8
+ P = TypeVar("P", bound=BaseModel)
9
+ T = TypeVar("T", bound=BaseModel)
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Pool(Generic[P, T]):
14
+ name: str
15
+ payload: type[P]
16
+ tags: type[T]
17
+
18
+ def __init__(self, name: str, *, payload: type[P], tags: type[T]) -> None:
19
+ object.__setattr__(self, "name", name)
20
+ object.__setattr__(self, "payload", payload)
21
+ object.__setattr__(self, "tags", tags)
22
+
23
+ def json_schema(self) -> dict[str, Any]:
24
+ return {
25
+ "kind": "pool",
26
+ "name": self.name,
27
+ "payload": self.payload.model_json_schema(),
28
+ "tags": self.tags.model_json_schema(),
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Queue(Generic[P]):
34
+ """Task queue. `name` is the automation_type that consumes from it."""
35
+ name: str
36
+ payload: type[P]
37
+
38
+ def __init__(self, name: str, *, payload: type[P]) -> None:
39
+ object.__setattr__(self, "name", name)
40
+ object.__setattr__(self, "payload", payload)
41
+
42
+ def json_schema(self) -> dict[str, Any]:
43
+ return {
44
+ "kind": "queue",
45
+ "name": self.name,
46
+ "payload": self.payload.model_json_schema(),
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Stream(Generic[P]):
52
+ """Output stream. `name` is the automation_type that emits to it."""
53
+ name: str
54
+ payload: type[P]
55
+
56
+ def __init__(self, name: str, *, payload: type[P]) -> None:
57
+ object.__setattr__(self, "name", name)
58
+ object.__setattr__(self, "payload", payload)
59
+
60
+ def json_schema(self) -> dict[str, Any]:
61
+ return {
62
+ "kind": "stream",
63
+ "name": self.name,
64
+ "payload": self.payload.model_json_schema(),
65
+ }
66
+
67
+
68
+ def validate_tag_filter(tags_type: type[BaseModel], **kwargs: Any) -> dict[str, Any]:
69
+ """Validate kwargs against a Tags model. TypeError on unknown field,
70
+ ValueError on type mismatch. Returns coerced values."""
71
+ fields = tags_type.model_fields
72
+ out: dict[str, Any] = {}
73
+ for k, v in kwargs.items():
74
+ if k not in fields:
75
+ known = ", ".join(sorted(fields.keys())) or "(none)"
76
+ raise TypeError(
77
+ f"unknown tag '{k}' for {tags_type.__name__}; known tags: {known}"
78
+ )
79
+ annotation = fields[k].annotation
80
+ try:
81
+ out[k] = TypeAdapter(annotation).validate_python(v)
82
+ except Exception as e:
83
+ raise ValueError(f"tag '{k}' value {v!r} invalid: {e}") from e
84
+ return out
85
+
86
+
87
+ __all__ = ["Pool", "Queue", "Stream", "validate_tag_filter", "P", "T"]
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ # ISO 3166-1 alpha-2. used by proxy providers to resolve string presets to
4
+ # concrete country lists. providers stringify the result however they need.
5
+
6
+ EU_27 = [
7
+ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
8
+ "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
9
+ "PL", "PT", "RO", "SK", "SI", "ES", "SE",
10
+ ]
11
+ EU_PLUS_GB = EU_27 + ["GB"]
12
+ NA = ["US", "CA"]
13
+ EU_US = EU_27 + ["US"]
14
+ LATAM = ["AR", "BR", "CL", "CO", "MX", "PE", "UY"]
15
+ APAC = ["AU", "JP", "KR", "SG", "HK", "TW", "NZ", "MY", "TH", "ID", "PH", "VN"]
16
+
17
+ _PRESETS: dict[str, list[str]] = {
18
+ "EU": EU_27,
19
+ "EU+UK": EU_PLUS_GB,
20
+ "EU+GB": EU_PLUS_GB,
21
+ "NA": NA,
22
+ "EU+US": EU_US,
23
+ "EU+NA": EU_27 + NA,
24
+ "LATAM": LATAM,
25
+ "APAC": APAC,
26
+ "WORLDWIDE": [],
27
+ "GLOBAL": [],
28
+ "ANY": [],
29
+ }
30
+
31
+
32
+ def resolve_countries(spec: str | list[str] | None) -> list[str]:
33
+ # returns uppercase ISO codes. empty list means "no restriction" (worldwide).
34
+ if spec is None:
35
+ return []
36
+ if isinstance(spec, list):
37
+ return [c.strip().upper() for c in spec if c and c.strip()]
38
+ s = spec.strip()
39
+ if not s:
40
+ return []
41
+ key = s.upper()
42
+ if key in _PRESETS:
43
+ return list(_PRESETS[key])
44
+ if "," in s:
45
+ return [p.strip().upper() for p in s.split(",") if p.strip()]
46
+ return [key]
47
+
48
+
49
+ def known_presets() -> list[str]:
50
+ return sorted(_PRESETS.keys())
fleet/core/events.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ import redis.asyncio as aioredis
9
+
10
+ # lightweight pub/sub on redis. ephemeral — no persistence. for signals,
11
+ # not for work hand-off (use stream.py for that).
12
+
13
+
14
+ class EventBus:
15
+ def __init__(self, r: aioredis.Redis) -> None:
16
+ self._r = r
17
+
18
+ async def publish(self, topic: str, payload: dict[str, Any]) -> None:
19
+ await self._r.publish(f"event:{topic}", json.dumps(payload))
20
+
21
+ async def subscribe(self, topic: str) -> AsyncIterator[dict[str, Any]]:
22
+ # async generator that yields payloads forever. cancel the consumer
23
+ # task to stop. consumer is responsible for handling backpressure.
24
+ pubsub = self._r.pubsub()
25
+ await pubsub.subscribe(f"event:{topic}")
26
+ try:
27
+ async for msg in pubsub.listen():
28
+ if msg is None or msg.get("type") != "message":
29
+ continue
30
+ data = msg.get("data")
31
+ if data is None:
32
+ continue
33
+ try:
34
+ yield json.loads(data)
35
+ except (json.JSONDecodeError, TypeError):
36
+ continue
37
+ finally:
38
+ try:
39
+ await pubsub.unsubscribe(f"event:{topic}")
40
+ await pubsub.aclose()
41
+ except Exception:
42
+ pass
43
+
44
+
45
+ async def first(topic: str, bus: EventBus, *, timeout: float = 5.0) -> dict[str, Any] | None:
46
+ # helper for tests / one-shot waits. returns None on timeout.
47
+ async def _consume() -> dict[str, Any]:
48
+ async for ev in bus.subscribe(topic):
49
+ return ev
50
+ raise RuntimeError("unreachable")
51
+
52
+ try:
53
+ return await asyncio.wait_for(_consume(), timeout=timeout)
54
+ except asyncio.TimeoutError:
55
+ return None
fleet/core/logging.py ADDED
@@ -0,0 +1,97 @@
1
+ """Logging setup: text or JSON, with contextvars threaded into every record.
2
+
3
+ Set `FLEET_LOG_FORMAT=json` to emit structured logs. Set `LOG_LEVEL` for the
4
+ threshold. Plugins call `bind_log_context(...)` to attach worker_id /
5
+ slot_id / automation_type / task_id to records emitted from their slot.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextvars
11
+ import json
12
+ import logging
13
+ import os
14
+ from typing import Any
15
+
16
+ _context: contextvars.ContextVar[dict[str, Any]] = contextvars.ContextVar(
17
+ "_fleet_log_context", default={}
18
+ )
19
+
20
+
21
+ def bind_log_context(**kwargs: Any) -> contextvars.Token:
22
+ """Add fields to the current async context's log records.
23
+
24
+ Returns a Token; pass it to reset_log_context to roll back.
25
+ """
26
+ merged = {**_context.get(), **kwargs}
27
+ return _context.set(merged)
28
+
29
+
30
+ def reset_log_context(token: contextvars.Token) -> None:
31
+ _context.reset(token)
32
+
33
+
34
+ class _ContextFilter(logging.Filter):
35
+ def filter(self, record: logging.LogRecord) -> bool:
36
+ ctx = _context.get()
37
+ for k, v in ctx.items():
38
+ if not hasattr(record, k):
39
+ setattr(record, k, v)
40
+ return True
41
+
42
+
43
+ _STANDARD_LOGRECORD_FIELDS = frozenset({
44
+ "args", "asctime", "created", "exc_info", "exc_text", "filename",
45
+ "funcName", "levelname", "levelno", "lineno", "message", "module",
46
+ "msecs", "msg", "name", "pathname", "process", "processName",
47
+ "relativeCreated", "stack_info", "thread", "threadName",
48
+ "taskName",
49
+ })
50
+
51
+
52
+ class _JsonFormatter(logging.Formatter):
53
+ def format(self, record: logging.LogRecord) -> str:
54
+ # record.created is seconds-since-epoch float; iso-format with ms precision.
55
+ from datetime import datetime, timezone
56
+ ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec="milliseconds")
57
+ out: dict[str, Any] = {
58
+ "ts": ts,
59
+ "level": record.levelname,
60
+ "logger": record.name,
61
+ "msg": record.getMessage(),
62
+ }
63
+ if record.exc_info:
64
+ out["exc"] = self.formatException(record.exc_info)
65
+ for k, v in record.__dict__.items():
66
+ if k in _STANDARD_LOGRECORD_FIELDS or k.startswith("_"):
67
+ continue
68
+ out[k] = v
69
+ return json.dumps(out, default=str)
70
+
71
+
72
+ def configure_logging(
73
+ level: str | None = None,
74
+ fmt: str | None = None,
75
+ ) -> None:
76
+ """Idempotent log setup. Reads LOG_LEVEL and FLEET_LOG_FORMAT from env
77
+ when args are None."""
78
+ lvl = (level or os.environ.get("LOG_LEVEL", "INFO")).upper()
79
+ chosen_fmt = (fmt or os.environ.get("FLEET_LOG_FORMAT", "text")).lower()
80
+
81
+ root = logging.getLogger()
82
+ for h in list(root.handlers):
83
+ root.removeHandler(h)
84
+
85
+ handler = logging.StreamHandler()
86
+ if chosen_fmt == "json":
87
+ handler.setFormatter(_JsonFormatter())
88
+ else:
89
+ handler.setFormatter(logging.Formatter(
90
+ "%(asctime)s %(levelname)s %(name)s: %(message)s"
91
+ ))
92
+ handler.addFilter(_ContextFilter())
93
+ root.addHandler(handler)
94
+ root.setLevel(lvl)
95
+
96
+
97
+ __all__ = ["bind_log_context", "configure_logging", "reset_log_context"]