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/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"]
|