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
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from typing import Any, AsyncIterator, Generic, Optional, TypeVar
9
+
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ from fleet.core.backend import Backend, RawItem
13
+ from fleet.core.contract import Pool, Queue, Stream, validate_tag_filter
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ P = TypeVar("P", bound=BaseModel)
18
+ T = TypeVar("T", bound=BaseModel)
19
+
20
+
21
+
22
+ @dataclass
23
+ class PoolItem(Generic[P, T]):
24
+ id: str
25
+ payload: P
26
+ tags: T
27
+ expires_at: Optional[float]
28
+ _handle: "PoolHandle[P, T]"
29
+
30
+ async def release(self) -> None:
31
+ await self._handle._backend.pool_release(self._handle._spec.name, self.id)
32
+
33
+ async def evict(self) -> None:
34
+ await self._handle._backend.pool_remove(self._handle._spec.name, self.id)
35
+
36
+ async def mark_bad(self, reason: str) -> None:
37
+ """Evict and log. Plugins can override later via a hook for stats."""
38
+ logger.info("pool %s item %s marked bad: %s", self._handle._spec.name, self.id, reason)
39
+ await self.evict()
40
+
41
+
42
+ class PoolFilter(Generic[P, T]):
43
+ def __init__(self, handle: "PoolHandle[P, T]", conditions: dict[str, Any]) -> None:
44
+ self._handle = handle
45
+ self._conditions = conditions
46
+
47
+ def where(self, **kwargs: Any) -> "PoolFilter[P, T]":
48
+ # validate kwargs against tag schema; chainable.
49
+ validated = validate_tag_filter(self._handle._spec.tags, **kwargs)
50
+ return PoolFilter(self._handle, {**self._conditions, **validated})
51
+
52
+ async def list(self) -> list[PoolItem[P, T]]:
53
+ raws = await self._handle._backend.pool_list(self._handle._spec.name)
54
+ out: list[PoolItem[P, T]] = []
55
+ for r in raws:
56
+ if r.in_use_until is not None:
57
+ continue
58
+ if not all(r.tags.get(k) == v for k, v in self._conditions.items()):
59
+ continue
60
+ item = self._handle._wrap(r)
61
+ if item is not None:
62
+ out.append(item)
63
+ return out
64
+
65
+ async def size(self) -> int:
66
+ return len(await self.list())
67
+
68
+ @asynccontextmanager
69
+ async def claim(self, *, hold_seconds: int = 30) -> AsyncIterator[Optional[PoolItem[P, T]]]:
70
+ raw = await self._handle._backend.pool_claim_any(
71
+ self._handle._spec.name, self._conditions, hold_seconds,
72
+ )
73
+ if raw is None:
74
+ yield None
75
+ return
76
+ item = self._handle._wrap(raw)
77
+ if item is None:
78
+ # decode failure — release the claim, treat as miss
79
+ await self._handle._backend.pool_release(self._handle._spec.name, raw.id)
80
+ yield None
81
+ return
82
+ try:
83
+ yield item
84
+ finally:
85
+ # if the user evicted, the claim is gone too. release is idempotent.
86
+ try:
87
+ await self._handle._backend.pool_release(self._handle._spec.name, raw.id)
88
+ except Exception:
89
+ logger.debug("pool release failed", exc_info=True)
90
+
91
+
92
+ class PoolHandle(Generic[P, T]):
93
+ def __init__(self, spec: Pool[P, T], backend: Backend) -> None:
94
+ self._spec = spec
95
+ self._backend = backend
96
+
97
+ @property
98
+ def name(self) -> str:
99
+ return self._spec.name
100
+
101
+ def where(self, **kwargs: Any) -> PoolFilter[P, T]:
102
+ return PoolFilter(self, {}).where(**kwargs)
103
+
104
+ async def put(
105
+ self,
106
+ *,
107
+ payload: P,
108
+ tags: T,
109
+ ttl_seconds: Optional[int] = None,
110
+ id: Optional[str] = None,
111
+ ) -> str:
112
+ # local pydantic validation (re-construct enforces type) plus tags type check.
113
+ if not isinstance(payload, self._spec.payload):
114
+ raise TypeError(
115
+ f"pool {self._spec.name}: payload must be {self._spec.payload.__name__}, "
116
+ f"got {type(payload).__name__}"
117
+ )
118
+ if not isinstance(tags, self._spec.tags):
119
+ raise TypeError(
120
+ f"pool {self._spec.name}: tags must be {self._spec.tags.__name__}, "
121
+ f"got {type(tags).__name__}"
122
+ )
123
+ item_id = id or uuid.uuid4().hex
124
+ await self._backend.pool_put(
125
+ self._spec.name,
126
+ item_id,
127
+ payload.model_dump_json().encode("utf-8"),
128
+ tags.model_dump(mode="json"),
129
+ ttl_seconds,
130
+ )
131
+ return item_id
132
+
133
+ async def list(self) -> list[PoolItem[P, T]]:
134
+ return await PoolFilter(self, {}).list()
135
+
136
+ async def size(self) -> int:
137
+ return await PoolFilter(self, {}).size()
138
+
139
+ def _wrap(self, raw: RawItem) -> Optional[PoolItem[P, T]]:
140
+ try:
141
+ payload = self._spec.payload.model_validate_json(raw.payload)
142
+ tags = self._spec.tags.model_validate(raw.tags)
143
+ except ValidationError:
144
+ logger.warning("pool %s item %s failed schema validation; dropping", self._spec.name, raw.id)
145
+ return None
146
+ return PoolItem(
147
+ id=raw.id,
148
+ payload=payload,
149
+ tags=tags,
150
+ expires_at=raw.expires_at,
151
+ _handle=self,
152
+ )
153
+
154
+
155
+
156
+ class KV:
157
+ def __init__(self, backend: Backend, namespace: str) -> None:
158
+ self._backend = backend
159
+ self._ns = namespace
160
+
161
+ async def set(self, key: str, value: Any, *, ttl_seconds: Optional[int] = None) -> None:
162
+ # value can be a pydantic model, dict, str, int, ..., serialized as json
163
+ import json
164
+ if hasattr(value, "model_dump_json"):
165
+ blob = value.model_dump_json().encode("utf-8")
166
+ else:
167
+ blob = json.dumps(value).encode("utf-8")
168
+ await self._backend.kv_set(self._ns, key, blob, ttl_seconds)
169
+
170
+ async def get(self, key: str) -> Any:
171
+ import json
172
+ raw = await self._backend.kv_get(self._ns, key)
173
+ if raw is None:
174
+ return None
175
+ try:
176
+ return json.loads(raw.decode("utf-8"))
177
+ except Exception:
178
+ return None
179
+
180
+ async def get_as(self, key: str, model: type[BaseModel]) -> Optional[BaseModel]:
181
+ raw = await self._backend.kv_get(self._ns, key)
182
+ if raw is None:
183
+ return None
184
+ return model.model_validate_json(raw)
185
+
186
+ async def delete(self, key: str) -> bool:
187
+ return await self._backend.kv_del(self._ns, key)
188
+
189
+
190
+
191
+ class Lock:
192
+ def __init__(self, backend: Backend, name: str, hold_seconds: int, wait_seconds: float) -> None:
193
+ self._backend = backend
194
+ self._name = name
195
+ self._hold = hold_seconds
196
+ self._wait = wait_seconds
197
+ self._token: Optional[str] = None
198
+
199
+ async def __aenter__(self) -> bool:
200
+ self._token = await self._backend.lock_acquire(self._name, self._hold, self._wait)
201
+ return self._token is not None
202
+
203
+ async def __aexit__(self, *exc) -> None:
204
+ if self._token is not None:
205
+ try:
206
+ await self._backend.lock_release(self._name, self._token)
207
+ except Exception:
208
+ logger.debug("lock release failed", exc_info=True)
209
+ self._token = None
210
+
211
+
212
+
213
+ class Counter:
214
+ def __init__(self, backend: Backend, name: str) -> None:
215
+ self._backend = backend
216
+ self._name = name
217
+
218
+ async def incr(self, by: int = 1) -> int:
219
+ return await self._backend.counter_incr(self._name, by)
220
+
221
+ async def decr(self, by: int = 1) -> int:
222
+ return await self._backend.counter_incr(self._name, -by)
223
+
224
+ async def get(self) -> int:
225
+ return await self._backend.counter_get(self._name)
226
+
227
+ async def set(self, value: int) -> None:
228
+ await self._backend.counter_set(self._name, value)
229
+
230
+ async def reset(self) -> None:
231
+ await self._backend.counter_del(self._name)
232
+
233
+
234
+
235
+ class QueueHandle(Generic[P]):
236
+ def __init__(self, spec: Queue[P], backend: Backend) -> None:
237
+ self._spec = spec
238
+ self._backend = backend
239
+
240
+ async def push(
241
+ self,
242
+ payload: P,
243
+ *,
244
+ task_id: Optional[str] = None,
245
+ priority: int = 0,
246
+ ttl_seconds: Optional[int] = None,
247
+ ) -> str:
248
+ if not isinstance(payload, self._spec.payload):
249
+ raise TypeError(
250
+ f"queue {self._spec.name}: payload must be {self._spec.payload.__name__}, "
251
+ f"got {type(payload).__name__}"
252
+ )
253
+ import time
254
+ tid = task_id or uuid.uuid4().hex
255
+ envelope: dict[str, Any] = {
256
+ "id": tid,
257
+ "payload": payload.model_dump(mode="json"),
258
+ "attempt": 0,
259
+ "submitted_at": time.time(),
260
+ }
261
+ if ttl_seconds is not None:
262
+ envelope["ttl_seconds"] = ttl_seconds
263
+ body = json.dumps(envelope).encode("utf-8")
264
+ await self._backend.queue_push(self._spec.name, body, priority=priority)
265
+ return tid
266
+
267
+
268
+
269
+ class StreamReader(Generic[P]):
270
+ def __init__(self, spec: Stream[P], backend: Backend) -> None:
271
+ self._spec = spec
272
+ self._backend = backend
273
+
274
+ async def pop(self, n: int = 1) -> list[P]:
275
+ raw_list = await self._backend.stream_pop(self._spec.name, n)
276
+ out: list[P] = []
277
+ for raw in raw_list:
278
+ try:
279
+ envelope = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
280
+ out.append(self._spec.payload.model_validate(envelope.get("payload", {})))
281
+ except (json.JSONDecodeError, ValidationError):
282
+ logger.warning("stream %s: bad item, dropping", self._spec.name)
283
+ continue
284
+ return out
285
+
286
+ async def peek(self, n: int = 10) -> list[P]:
287
+ raw_list = await self._backend.stream_peek(self._spec.name, n)
288
+ out: list[P] = []
289
+ for raw in raw_list:
290
+ try:
291
+ envelope = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
292
+ out.append(self._spec.payload.model_validate(envelope.get("payload", {})))
293
+ except (json.JSONDecodeError, ValidationError):
294
+ continue
295
+ return out
296
+
297
+ async def length(self) -> int:
298
+ return await self._backend.stream_length(self._spec.name)
299
+
300
+
301
+ __all__ = [
302
+ "Counter",
303
+ "KV",
304
+ "Lock",
305
+ "PoolFilter",
306
+ "PoolHandle",
307
+ "PoolItem",
308
+ "QueueHandle",
309
+ "StreamReader",
310
+ ]
fleet/core/protocol.py ADDED
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Annotated, Any, Literal, Union
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
7
+
8
+ PROTOCOL_VERSION: int = 1
9
+
10
+ WorkerState = Literal["IDLE", "RUNNING", "ERROR", "DRAINING"]
11
+
12
+
13
+ class ProtocolError(Exception):
14
+ pass
15
+
16
+
17
+ class _Frame(BaseModel):
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+
21
+ class Hardware(BaseModel):
22
+ model_config = ConfigDict(extra="forbid")
23
+ cores: int = Field(ge=1)
24
+ ram_gb: int = Field(ge=0)
25
+ hostname: str
26
+ kernel: str
27
+
28
+
29
+ # worker -> master
30
+
31
+
32
+ class Register(_Frame):
33
+ type: Literal["register"] = "register"
34
+ worker_id: str
35
+ automation_type: str
36
+ protocol_version: int
37
+ hardware: Hardware
38
+ fleet_core_version: str = ""
39
+ """Worker's installed fleet-core version. Master logs a mismatch warning
40
+ when the major differs from its own. Empty for backwards compat with
41
+ pre-0.1 workers."""
42
+
43
+
44
+ class State(_Frame):
45
+ type: Literal["state"] = "state"
46
+ state: WorkerState
47
+ config_gen: int = Field(ge=0)
48
+ ts: int = Field(ge=0)
49
+ last_error: str | None = None
50
+
51
+
52
+ class SlotStats(_Frame):
53
+ """Per-slot snapshot inside a Stats frame."""
54
+ slot_id: int = Field(ge=0)
55
+ age_seconds: float = Field(ge=0.0)
56
+ cpu_pct: float = Field(ge=0.0, default=0.0)
57
+ rss_mb: int = Field(ge=0, default=0)
58
+ counters: dict[str, int] = Field(default_factory=dict)
59
+
60
+
61
+ class Stats(_Frame):
62
+ type: Literal["stats"] = "stats"
63
+ slots: int = Field(ge=0)
64
+ cpu_pct: float = Field(ge=0.0)
65
+ rss_mb: int = Field(ge=0)
66
+ ts: int = Field(ge=0)
67
+ metrics: dict[str, Any] = Field(default_factory=dict)
68
+ per_slot: list[SlotStats] = Field(default_factory=list)
69
+
70
+
71
+ class Output(_Frame):
72
+ # generic worker -> master output frame. payload is plugin-typed (validated server-side).
73
+ type: Literal["output"] = "output"
74
+ slot_id: int = Field(ge=0)
75
+ ts: int = Field(ge=0)
76
+ payload: dict[str, Any]
77
+
78
+
79
+ # master -> worker
80
+
81
+
82
+ class ConfigChanged(_Frame):
83
+ type: Literal["config_changed"] = "config_changed"
84
+ config_gen: int = Field(ge=0)
85
+
86
+
87
+ class TaskAssign(_Frame):
88
+ # used by BatchAutomation. master pushes a single task to a worker slot.
89
+ type: Literal["task_assign"] = "task_assign"
90
+ task_id: str
91
+ payload: dict[str, Any]
92
+
93
+
94
+ class Ping(_Frame):
95
+ type: Literal["ping"] = "ping"
96
+ id: str
97
+
98
+
99
+ class Drain(_Frame):
100
+ """Master → worker: drain in-flight slots and exit cleanly.
101
+
102
+ After receiving, the worker sets shutdown on every slot, waits for
103
+ them to complete (no hard cancel), reports DRAINING, then exits.
104
+ """
105
+ type: Literal["drain"] = "drain"
106
+
107
+
108
+ Message = Annotated[
109
+ Union[Register, State, Stats, Output, ConfigChanged, TaskAssign, Ping, Drain],
110
+ Field(discriminator="type"),
111
+ ]
112
+
113
+
114
+ _TYPE_REGISTRY: dict[str, type[BaseModel]] = {
115
+ "register": Register,
116
+ "state": State,
117
+ "stats": Stats,
118
+ "output": Output,
119
+ "config_changed": ConfigChanged,
120
+ "task_assign": TaskAssign,
121
+ "ping": Ping,
122
+ "drain": Drain,
123
+ }
124
+
125
+
126
+ def encode(msg: BaseModel) -> str:
127
+ if not isinstance(msg, BaseModel):
128
+ raise TypeError(f"encode() expected pydantic BaseModel, got {type(msg).__name__}")
129
+ return msg.model_dump_json()
130
+
131
+
132
+ def decode(raw: str | bytes | bytearray) -> BaseModel:
133
+ if not isinstance(raw, (str, bytes, bytearray)):
134
+ raise ProtocolError(f"decode() expects str/bytes, got {type(raw).__name__}")
135
+ try:
136
+ data: Any = json.loads(raw)
137
+ except json.JSONDecodeError as e:
138
+ raise ProtocolError(f"malformed JSON: {e}") from e
139
+ if not isinstance(data, dict):
140
+ raise ProtocolError(f"frame root must be object, got {type(data).__name__}")
141
+ if "type" not in data:
142
+ raise ProtocolError("frame missing required field 'type'")
143
+ tag = data["type"]
144
+ if not isinstance(tag, str):
145
+ raise ProtocolError(f"frame 'type' must be string, got {type(tag).__name__}")
146
+ cls = _TYPE_REGISTRY.get(tag)
147
+ if cls is None:
148
+ raise ProtocolError(f"unknown message type: {tag!r}")
149
+ try:
150
+ return cls.model_validate(data)
151
+ except ValidationError as e:
152
+ raise ProtocolError(f"validation failed for type={tag!r}: {e}") from e
153
+
154
+
155
+ __all__ = [
156
+ "ConfigChanged",
157
+ "Drain",
158
+ "Hardware",
159
+ "Message",
160
+ "Output",
161
+ "PROTOCOL_VERSION",
162
+ "Ping",
163
+ "ProtocolError",
164
+ "Register",
165
+ "State",
166
+ "Stats",
167
+ "TaskAssign",
168
+ "WorkerState",
169
+ "decode",
170
+ "encode",
171
+ ]
fleet/core/proxy.py ADDED
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata as md
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, AsyncIterator, ClassVar, Optional, Protocol, runtime_checkable
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class ProxySession:
14
+ url: str
15
+ metadata: dict[str, Any] = field(default_factory=dict)
16
+ _release: Optional[Any] = None
17
+ _report_bad: Optional[Any] = None
18
+
19
+ async def release(self) -> None:
20
+ if self._release is not None:
21
+ await self._release(self)
22
+
23
+ async def report_bad(self, reason: str) -> None:
24
+ if self._report_bad is not None:
25
+ await self._report_bad(self, reason)
26
+
27
+
28
+ @runtime_checkable
29
+ class ProxyProvider(Protocol):
30
+ name: ClassVar[str]
31
+
32
+ @classmethod
33
+ def from_config(cls, cfg: dict[str, Any]) -> "ProxyProvider": ...
34
+
35
+ async def acquire(
36
+ self,
37
+ *,
38
+ slot_id: int,
39
+ sticky: bool = True,
40
+ country: Optional[str | list[str]] = None,
41
+ ) -> ProxySession: ...
42
+
43
+
44
+ class ProxyHandle:
45
+ # what ctx.proxy returns. wraps a provider and the slot's identity.
46
+ def __init__(self, provider: Optional[ProxyProvider], slot_id: int) -> None:
47
+ self._provider = provider
48
+ self._slot_id = slot_id
49
+
50
+ @property
51
+ def provider(self) -> Optional[ProxyProvider]:
52
+ return self._provider
53
+
54
+ @asynccontextmanager
55
+ async def session(
56
+ self,
57
+ *,
58
+ sticky: bool = True,
59
+ country: Optional[str | list[str]] = None,
60
+ ) -> AsyncIterator[ProxySession]:
61
+ if self._provider is None:
62
+ yield ProxySession(url="")
63
+ return
64
+ sess = await self._provider.acquire(
65
+ slot_id=self._slot_id, sticky=sticky, country=country
66
+ )
67
+ try:
68
+ yield sess
69
+ finally:
70
+ try:
71
+ await sess.release()
72
+ except Exception:
73
+ logger.debug("proxy session release failed", exc_info=True)
74
+
75
+
76
+ _REGISTRY: dict[str, type] = {}
77
+
78
+
79
+ def register_provider(name: str):
80
+ def _wrap(cls):
81
+ if not hasattr(cls, "from_config"):
82
+ raise TypeError(f"{cls.__name__} must define classmethod from_config")
83
+ cls.name = name
84
+ if name in _REGISTRY and _REGISTRY[name] is not cls:
85
+ raise ValueError(f"proxy provider '{name}' already registered")
86
+ _REGISTRY[name] = cls
87
+ return cls
88
+
89
+ return _wrap
90
+
91
+
92
+ def get_provider_registry() -> dict[str, type]:
93
+ return dict(_REGISTRY)
94
+
95
+
96
+ def load_provider_entry_points(group: str = "fleet.providers.proxy") -> dict[str, type]:
97
+ try:
98
+ eps = md.entry_points(group=group)
99
+ except TypeError:
100
+ eps = md.entry_points().get(group, []) # type: ignore[assignment]
101
+ for ep in eps:
102
+ try:
103
+ ep.load()
104
+ except Exception:
105
+ logger.exception("failed to load proxy provider entry-point %s", ep.name)
106
+ return get_provider_registry()
107
+
108
+
109
+ def build_provider(name: str, cfg: dict[str, Any]) -> ProxyProvider:
110
+ load_provider_entry_points()
111
+ cls = _REGISTRY.get(name)
112
+ if cls is None:
113
+ known = ", ".join(sorted(_REGISTRY.keys())) or "(none installed)"
114
+ raise ValueError(f"unknown proxy provider '{name}'. installed: {known}")
115
+ return cls.from_config(cfg)
116
+
117
+
118
+
119
+ @register_provider("static")
120
+ class StaticProvider:
121
+ # the original behavior: one URL, always the same.
122
+ def __init__(self, url: Optional[str]) -> None:
123
+ self._url = url
124
+
125
+ @classmethod
126
+ def from_config(cls, cfg: dict[str, Any]) -> "StaticProvider":
127
+ return cls(url=cfg.get("url"))
128
+
129
+ async def acquire(
130
+ self,
131
+ *,
132
+ slot_id: int,
133
+ sticky: bool = True,
134
+ country: Optional[str | list[str]] = None,
135
+ ) -> ProxySession:
136
+ return ProxySession(url=self._url or "")
137
+
138
+
139
+
140
+ @register_provider("rotating-list")
141
+ class RotatingListProvider:
142
+ def __init__(self, urls: list[str]) -> None:
143
+ self._urls = urls
144
+ self._next = 0
145
+
146
+ @classmethod
147
+ def from_config(cls, cfg: dict[str, Any]) -> "RotatingListProvider":
148
+ urls = list(cfg.get("urls") or [])
149
+ if not urls:
150
+ raise ValueError("rotating-list provider requires non-empty 'urls' list")
151
+ return cls(urls=urls)
152
+
153
+ async def acquire(
154
+ self,
155
+ *,
156
+ slot_id: int,
157
+ sticky: bool = True,
158
+ country: Optional[str | list[str]] = None,
159
+ ) -> ProxySession:
160
+ if sticky:
161
+ # deterministic by slot_id so the same slot keeps the same URL.
162
+ url = self._urls[slot_id % len(self._urls)]
163
+ else:
164
+ url = self._urls[self._next % len(self._urls)]
165
+ self._next += 1
166
+ return ProxySession(url=url, metadata={"sticky": sticky})