fleet-python 0.2.66b2__py3-none-any.whl → 0.2.105__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.
- examples/export_tasks.py +16 -5
- examples/export_tasks_filtered.py +245 -0
- examples/fetch_tasks.py +230 -0
- examples/import_tasks.py +140 -8
- examples/iterate_verifiers.py +725 -0
- fleet/__init__.py +128 -5
- fleet/_async/__init__.py +27 -3
- fleet/_async/base.py +24 -9
- fleet/_async/client.py +938 -41
- fleet/_async/env/client.py +60 -3
- fleet/_async/instance/client.py +52 -7
- fleet/_async/models.py +15 -0
- fleet/_async/resources/api.py +200 -0
- fleet/_async/resources/sqlite.py +1801 -46
- fleet/_async/tasks.py +122 -25
- fleet/_async/verifiers/bundler.py +22 -21
- fleet/_async/verifiers/verifier.py +25 -19
- fleet/agent/__init__.py +32 -0
- fleet/agent/gemini_cua/Dockerfile +45 -0
- fleet/agent/gemini_cua/__init__.py +10 -0
- fleet/agent/gemini_cua/agent.py +759 -0
- fleet/agent/gemini_cua/mcp/main.py +108 -0
- fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
- fleet/agent/gemini_cua/mcp_server/main.py +105 -0
- fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
- fleet/agent/gemini_cua/requirements.txt +5 -0
- fleet/agent/gemini_cua/start.sh +30 -0
- fleet/agent/orchestrator.py +854 -0
- fleet/agent/types.py +49 -0
- fleet/agent/utils.py +34 -0
- fleet/base.py +34 -9
- fleet/cli.py +1061 -0
- fleet/client.py +1060 -48
- fleet/config.py +1 -1
- fleet/env/__init__.py +16 -0
- fleet/env/client.py +60 -3
- fleet/eval/__init__.py +15 -0
- fleet/eval/uploader.py +231 -0
- fleet/exceptions.py +8 -0
- fleet/instance/client.py +53 -8
- fleet/instance/models.py +1 -0
- fleet/models.py +303 -0
- fleet/proxy/__init__.py +25 -0
- fleet/proxy/proxy.py +453 -0
- fleet/proxy/whitelist.py +244 -0
- fleet/resources/api.py +200 -0
- fleet/resources/sqlite.py +1845 -46
- fleet/tasks.py +113 -20
- fleet/utils/__init__.py +7 -0
- fleet/utils/http_logging.py +178 -0
- fleet/utils/logging.py +13 -0
- fleet/utils/playwright.py +440 -0
- fleet/verifiers/bundler.py +22 -21
- fleet/verifiers/db.py +985 -1
- fleet/verifiers/decorator.py +1 -1
- fleet/verifiers/verifier.py +25 -19
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
- fleet_python-0.2.105.dist-info/RECORD +115 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
- fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
- tests/test_app_method.py +85 -0
- tests/test_expect_exactly.py +4148 -0
- tests/test_expect_only.py +2593 -0
- tests/test_instance_dispatch.py +607 -0
- tests/test_sqlite_resource_dual_mode.py +263 -0
- tests/test_sqlite_shared_memory_behavior.py +117 -0
- fleet_python-0.2.66b2.dist-info/RECORD +0 -81
- tests/test_verifier_security.py +0 -427
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
fleet/tasks.py
CHANGED
|
@@ -36,9 +36,16 @@ class Task(BaseModel):
|
|
|
36
36
|
)
|
|
37
37
|
verifier_id: Optional[str] = Field(None, description="Verifier identifier")
|
|
38
38
|
verifier_sha: Optional[str] = Field(None, description="Verifier SHA256 hash")
|
|
39
|
+
verifier_runtime_version: Optional[str] = Field(None, description="Verifier runtime version")
|
|
39
40
|
metadata: Optional[Dict[str, Any]] = Field(
|
|
40
41
|
default_factory=dict, description="Additional task metadata"
|
|
41
42
|
)
|
|
43
|
+
writer_metadata: Optional[Dict[str, Any]] = Field(
|
|
44
|
+
None, description="Metadata filled by task writer"
|
|
45
|
+
)
|
|
46
|
+
qa_metadata: Optional[Dict[str, Any]] = Field(
|
|
47
|
+
None, description="Metadata filled by QA reviewer"
|
|
48
|
+
)
|
|
42
49
|
output_json_schema: Optional[Dict[str, Any]] = Field(
|
|
43
50
|
None, description="JSON schema for expected output format"
|
|
44
51
|
)
|
|
@@ -199,26 +206,37 @@ class Task(BaseModel):
|
|
|
199
206
|
verifier_id=verifier_id,
|
|
200
207
|
verifier_key=self.key,
|
|
201
208
|
sha256=self.verifier_sha or "",
|
|
209
|
+
verifier_runtime_version=self.verifier_runtime_version or "",
|
|
202
210
|
)
|
|
203
211
|
self.verifier = verifier
|
|
204
212
|
|
|
205
|
-
def make_env(
|
|
213
|
+
def make_env(
|
|
214
|
+
self,
|
|
215
|
+
region: Optional[str] = None,
|
|
216
|
+
image_type: Optional[str] = None,
|
|
217
|
+
ttl_seconds: Optional[int] = None,
|
|
218
|
+
run_id: Optional[str] = None,
|
|
219
|
+
heartbeat_interval: Optional[int] = None,
|
|
220
|
+
):
|
|
206
221
|
"""Create an environment instance for this task's environment.
|
|
207
222
|
|
|
208
|
-
Uses the task's env_id (and version if present) to create the env.
|
|
223
|
+
Alias for make() method. Uses the task's env_id (and version if present) to create the env.
|
|
209
224
|
"""
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
225
|
+
return self.make(
|
|
226
|
+
region=region,
|
|
227
|
+
image_type=image_type,
|
|
228
|
+
ttl_seconds=ttl_seconds,
|
|
229
|
+
run_id=run_id,
|
|
230
|
+
heartbeat_interval=heartbeat_interval,
|
|
231
|
+
)
|
|
216
232
|
|
|
217
233
|
def make(
|
|
218
234
|
self,
|
|
219
235
|
region: Optional[str] = None,
|
|
220
236
|
image_type: Optional[str] = None,
|
|
221
237
|
ttl_seconds: Optional[int] = None,
|
|
238
|
+
run_id: Optional[str] = None,
|
|
239
|
+
heartbeat_interval: Optional[int] = None,
|
|
222
240
|
):
|
|
223
241
|
"""Create an environment instance with task's configuration.
|
|
224
242
|
|
|
@@ -226,11 +244,15 @@ class Task(BaseModel):
|
|
|
226
244
|
- env_key (env_id + version)
|
|
227
245
|
- data_key (data_id + data_version, if present)
|
|
228
246
|
- env_variables (if present)
|
|
247
|
+
- run_id (if present)
|
|
248
|
+
- heartbeat_interval (if present)
|
|
229
249
|
|
|
230
250
|
Args:
|
|
231
251
|
region: Optional AWS region for the environment
|
|
232
252
|
image_type: Optional image type for the environment
|
|
233
253
|
ttl_seconds: Optional TTL in seconds for the instance
|
|
254
|
+
run_id: Optional run ID to group instances
|
|
255
|
+
heartbeat_interval: Optional heartbeat interval in seconds (30-3600)
|
|
234
256
|
|
|
235
257
|
Returns:
|
|
236
258
|
Environment instance configured for this task
|
|
@@ -238,7 +260,7 @@ class Task(BaseModel):
|
|
|
238
260
|
Example:
|
|
239
261
|
task = fleet.Task(key="my-task", prompt="...", env_id="my-env",
|
|
240
262
|
data_id="my-data", data_version="v1.0")
|
|
241
|
-
env = task.make(region="us-west-2")
|
|
263
|
+
env = task.make(region="us-west-2", run_id="my-batch-123", heartbeat_interval=60)
|
|
242
264
|
"""
|
|
243
265
|
if not self.env_id:
|
|
244
266
|
raise ValueError("Task has no env_id defined")
|
|
@@ -253,11 +275,13 @@ class Task(BaseModel):
|
|
|
253
275
|
env_variables=self.env_variables if self.env_variables else None,
|
|
254
276
|
image_type=image_type,
|
|
255
277
|
ttl_seconds=ttl_seconds,
|
|
278
|
+
run_id=run_id,
|
|
279
|
+
heartbeat_interval=heartbeat_interval,
|
|
256
280
|
)
|
|
257
281
|
|
|
258
282
|
|
|
259
283
|
def verifier_from_string(
|
|
260
|
-
verifier_func: str, verifier_id: str, verifier_key: str, sha256: str = ""
|
|
284
|
+
verifier_func: str, verifier_id: str, verifier_key: str, sha256: str = "", verifier_runtime_version: str = ""
|
|
261
285
|
) -> "VerifierFunction":
|
|
262
286
|
"""Create a verifier function from string code.
|
|
263
287
|
|
|
@@ -266,20 +290,62 @@ def verifier_from_string(
|
|
|
266
290
|
verifier_id: Unique identifier for the verifier
|
|
267
291
|
verifier_key: Key/name for the verifier
|
|
268
292
|
sha256: SHA256 hash of the verifier code
|
|
293
|
+
verifier_runtime_version: Verifier runtime version
|
|
269
294
|
|
|
270
295
|
Returns:
|
|
271
296
|
VerifierFunction instance that can be used to verify tasks
|
|
272
297
|
"""
|
|
273
298
|
try:
|
|
274
299
|
import inspect
|
|
300
|
+
import re
|
|
301
|
+
import json
|
|
302
|
+
import string
|
|
275
303
|
from .verifiers import SyncVerifierFunction
|
|
276
304
|
from .verifiers.code import TASK_SUCCESSFUL_SCORE, TASK_FAILED_SCORE
|
|
277
305
|
from .verifiers.db import IgnoreConfig
|
|
278
|
-
from .verifiers.parsing import parse_and_validate_verifier
|
|
279
306
|
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
|
|
307
|
+
# Strip @verifier decorator if present to avoid double-wrapping
|
|
308
|
+
# Remove lines like: @verifier(key="...")
|
|
309
|
+
cleaned_code = re.sub(r"@verifier\([^)]*\)\s*\n", "", verifier_func)
|
|
310
|
+
# Also remove the verifier import if present
|
|
311
|
+
# Use MULTILINE flag to match beginning of lines with ^
|
|
312
|
+
cleaned_code = re.sub(r"^from fleet\.verifiers.*import.*verifier.*$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
313
|
+
cleaned_code = re.sub(r"^from fleet import verifier.*$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
314
|
+
cleaned_code = re.sub(r"^import fleet\.verifiers.*$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
315
|
+
cleaned_code = re.sub(r"^import fleet$\n?", "", cleaned_code, flags=re.MULTILINE)
|
|
316
|
+
|
|
317
|
+
# Define helper functions for verifier execution
|
|
318
|
+
_TRANSLATOR = str.maketrans(string.punctuation, " " * len(string.punctuation))
|
|
319
|
+
|
|
320
|
+
def _normalize_text(value: str) -> str:
|
|
321
|
+
text = value.lower().translate(_TRANSLATOR)
|
|
322
|
+
return "".join(text.split())
|
|
323
|
+
|
|
324
|
+
def _stringify_content(content: Any) -> str:
|
|
325
|
+
if isinstance(content, (dict, list)):
|
|
326
|
+
return json.dumps(content, sort_keys=True)
|
|
327
|
+
return str(content)
|
|
328
|
+
|
|
329
|
+
def normalized_contains(target: str, blob: Any) -> bool:
|
|
330
|
+
normalized_target = _normalize_text(target)
|
|
331
|
+
normalized_blob = _normalize_text(_stringify_content(blob))
|
|
332
|
+
return normalized_target in normalized_blob
|
|
333
|
+
|
|
334
|
+
def extract_numbers(text: str) -> list:
|
|
335
|
+
cleaned_text = text.replace(',', '')
|
|
336
|
+
pattern = r'-?\d+\.?\d*'
|
|
337
|
+
matches = re.findall(pattern, cleaned_text)
|
|
338
|
+
return [float(num) for num in matches]
|
|
339
|
+
|
|
340
|
+
def contains_number(text: str, target_number) -> bool:
|
|
341
|
+
numbers = extract_numbers(text)
|
|
342
|
+
try:
|
|
343
|
+
if isinstance(target_number, str):
|
|
344
|
+
target_number = target_number.replace(',', '')
|
|
345
|
+
target = float(target_number)
|
|
346
|
+
except (ValueError, AttributeError):
|
|
347
|
+
return False
|
|
348
|
+
return target in numbers
|
|
283
349
|
|
|
284
350
|
# Create a globals namespace with all required imports
|
|
285
351
|
exec_globals = globals().copy()
|
|
@@ -289,15 +355,24 @@ def verifier_from_string(
|
|
|
289
355
|
"TASK_FAILED_SCORE": TASK_FAILED_SCORE,
|
|
290
356
|
"IgnoreConfig": IgnoreConfig,
|
|
291
357
|
"Environment": object, # Add Environment type if needed
|
|
358
|
+
"normalized_contains": normalized_contains,
|
|
359
|
+
"extract_numbers": extract_numbers,
|
|
360
|
+
"contains_number": contains_number,
|
|
361
|
+
"json": json,
|
|
362
|
+
"re": re,
|
|
363
|
+
"string": string,
|
|
292
364
|
}
|
|
293
365
|
)
|
|
294
366
|
|
|
295
367
|
# Create a local namespace for executing the code
|
|
296
368
|
local_namespace = {}
|
|
297
369
|
|
|
298
|
-
# Execute the verifier code in the namespace
|
|
299
|
-
|
|
300
|
-
|
|
370
|
+
# Execute the cleaned verifier code in the namespace
|
|
371
|
+
exec(cleaned_code, exec_globals, local_namespace)
|
|
372
|
+
|
|
373
|
+
# Merge local_namespace into exec_globals so helper functions are accessible
|
|
374
|
+
# from the main verifier function when it's called
|
|
375
|
+
exec_globals.update(local_namespace)
|
|
301
376
|
|
|
302
377
|
# Find the function that was defined (not imported)
|
|
303
378
|
# Functions defined via exec have co_filename == '<string>'
|
|
@@ -318,6 +393,7 @@ def verifier_from_string(
|
|
|
318
393
|
verifier_id=verifier_id,
|
|
319
394
|
sha256=sha256,
|
|
320
395
|
raw_code=verifier_func,
|
|
396
|
+
verifier_runtime_version=verifier_runtime_version if verifier_runtime_version else None,
|
|
321
397
|
)
|
|
322
398
|
|
|
323
399
|
# Store additional metadata
|
|
@@ -387,7 +463,12 @@ def load_tasks(
|
|
|
387
463
|
|
|
388
464
|
|
|
389
465
|
def update_task(
|
|
390
|
-
task_key: str,
|
|
466
|
+
task_key: str,
|
|
467
|
+
prompt: Optional[str] = None,
|
|
468
|
+
verifier_code: Optional[str] = None,
|
|
469
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
470
|
+
writer_metadata: Optional[Dict[str, Any]] = None,
|
|
471
|
+
qa_metadata: Optional[Dict[str, Any]] = None,
|
|
391
472
|
):
|
|
392
473
|
"""Convenience function to update an existing task.
|
|
393
474
|
|
|
@@ -395,6 +476,9 @@ def update_task(
|
|
|
395
476
|
task_key: The key of the task to update
|
|
396
477
|
prompt: New prompt text for the task (optional)
|
|
397
478
|
verifier_code: Python code for task verification (optional)
|
|
479
|
+
metadata: Additional metadata for the task (optional)
|
|
480
|
+
writer_metadata: Metadata filled by task writer (optional)
|
|
481
|
+
qa_metadata: Metadata filled by QA reviewer (optional)
|
|
398
482
|
|
|
399
483
|
Returns:
|
|
400
484
|
TaskResponse containing the updated task details
|
|
@@ -402,16 +486,25 @@ def update_task(
|
|
|
402
486
|
Examples:
|
|
403
487
|
response = fleet.update_task("my-task", prompt="New prompt text")
|
|
404
488
|
response = fleet.update_task("my-task", verifier_code="def verify(env): return True")
|
|
489
|
+
response = fleet.update_task("my-task", metadata={"seed": 42, "story": "Updated story"})
|
|
490
|
+
response = fleet.update_task("my-task", writer_metadata={"author": "john"})
|
|
405
491
|
"""
|
|
406
492
|
from .global_client import get_client
|
|
407
493
|
|
|
408
494
|
client = get_client()
|
|
409
495
|
return client.update_task(
|
|
410
|
-
task_key=task_key,
|
|
496
|
+
task_key=task_key,
|
|
497
|
+
prompt=prompt,
|
|
498
|
+
verifier_code=verifier_code,
|
|
499
|
+
metadata=metadata,
|
|
500
|
+
writer_metadata=writer_metadata,
|
|
501
|
+
qa_metadata=qa_metadata,
|
|
411
502
|
)
|
|
412
503
|
|
|
413
504
|
|
|
414
|
-
def get_task(
|
|
505
|
+
def get_task(
|
|
506
|
+
task_key: str, version_id: Optional[str] = None, team_id: Optional[str] = None
|
|
507
|
+
):
|
|
415
508
|
"""Convenience function to get a task by key and optional version.
|
|
416
509
|
|
|
417
510
|
Args:
|
fleet/utils/__init__.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""HTTP traffic logging via httpx event hooks.
|
|
2
|
+
|
|
3
|
+
Captures request/response pairs and writes to JSONL file.
|
|
4
|
+
Works with any httpx-based client (including google-genai).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
# Sensitive headers to redact
|
|
16
|
+
SENSITIVE_HEADERS = {
|
|
17
|
+
"authorization", "x-api-key", "api-key", "x-goog-api-key",
|
|
18
|
+
"x-gemini-api-key", "x-openai-api-key", "x-anthropic-api-key",
|
|
19
|
+
"cookie", "set-cookie", "x-auth-token", "x-access-token",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _redact_headers(headers: dict) -> dict:
|
|
24
|
+
"""Redact sensitive headers."""
|
|
25
|
+
redacted = {}
|
|
26
|
+
for k, v in headers.items():
|
|
27
|
+
if k.lower() in SENSITIVE_HEADERS:
|
|
28
|
+
if len(str(v)) > 12:
|
|
29
|
+
redacted[k] = str(v)[:8] + "...[REDACTED]"
|
|
30
|
+
else:
|
|
31
|
+
redacted[k] = "[REDACTED]"
|
|
32
|
+
else:
|
|
33
|
+
redacted[k] = str(v)
|
|
34
|
+
return redacted
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HttpTrafficLogger:
|
|
38
|
+
"""Logs HTTP traffic to JSONL file."""
|
|
39
|
+
|
|
40
|
+
_instance: Optional["HttpTrafficLogger"] = None
|
|
41
|
+
_lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def __init__(self, log_file: Optional[Path] = None):
|
|
44
|
+
if log_file is None:
|
|
45
|
+
log_dir = Path.home() / ".fleet" / "proxy_logs"
|
|
46
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
log_file = log_dir / f"traffic_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
|
|
48
|
+
|
|
49
|
+
self.log_file = log_file
|
|
50
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._file = open(log_file, "a")
|
|
52
|
+
self._write_lock = threading.Lock()
|
|
53
|
+
self._request_times: dict = {} # Track request start times
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def get(cls, log_file: Optional[Path] = None) -> "HttpTrafficLogger":
|
|
57
|
+
"""Get singleton instance."""
|
|
58
|
+
with cls._lock:
|
|
59
|
+
if cls._instance is None:
|
|
60
|
+
cls._instance = cls(log_file)
|
|
61
|
+
return cls._instance
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def set_log_file(cls, log_file: Path):
|
|
65
|
+
"""Set log file for singleton (creates new instance if needed)."""
|
|
66
|
+
with cls._lock:
|
|
67
|
+
if cls._instance is not None:
|
|
68
|
+
cls._instance.close()
|
|
69
|
+
cls._instance = cls(log_file)
|
|
70
|
+
return cls._instance
|
|
71
|
+
|
|
72
|
+
def log_request(self, request) -> str:
|
|
73
|
+
"""Log request, return request ID for matching response."""
|
|
74
|
+
request_id = f"{id(request)}_{time.time()}"
|
|
75
|
+
self._request_times[request_id] = time.time()
|
|
76
|
+
return request_id
|
|
77
|
+
|
|
78
|
+
def log_response(self, request, response, request_id: Optional[str] = None):
|
|
79
|
+
"""Log complete request/response pair."""
|
|
80
|
+
start_time = self._request_times.pop(request_id, None) if request_id else None
|
|
81
|
+
duration_ms = int((time.time() - start_time) * 1000) if start_time else None
|
|
82
|
+
|
|
83
|
+
# Extract host
|
|
84
|
+
host = str(request.url.host) if hasattr(request.url, 'host') else str(request.url).split('/')[2]
|
|
85
|
+
|
|
86
|
+
# Build request entry
|
|
87
|
+
request_headers = dict(request.headers) if hasattr(request, 'headers') else {}
|
|
88
|
+
request_body = None
|
|
89
|
+
if hasattr(request, 'content') and request.content:
|
|
90
|
+
try:
|
|
91
|
+
body_bytes = request.content if isinstance(request.content, bytes) else bytes(request.content)
|
|
92
|
+
if len(body_bytes) < 50000:
|
|
93
|
+
request_body = body_bytes.decode('utf-8', errors='replace')
|
|
94
|
+
except:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# Build response entry
|
|
98
|
+
response_headers = dict(response.headers) if hasattr(response, 'headers') else {}
|
|
99
|
+
response_body = None
|
|
100
|
+
if hasattr(response, 'content') and response.content:
|
|
101
|
+
try:
|
|
102
|
+
if len(response.content) < 50000:
|
|
103
|
+
response_body = response.content.decode('utf-8', errors='replace')
|
|
104
|
+
except:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
entry = {
|
|
108
|
+
"type": "http",
|
|
109
|
+
"timestamp": datetime.now().isoformat(),
|
|
110
|
+
"host": host,
|
|
111
|
+
"duration_ms": duration_ms,
|
|
112
|
+
"logged_at": datetime.now().isoformat(),
|
|
113
|
+
"request": {
|
|
114
|
+
"method": str(request.method),
|
|
115
|
+
"url": str(request.url),
|
|
116
|
+
"headers": _redact_headers(request_headers),
|
|
117
|
+
"body": request_body,
|
|
118
|
+
"body_length": len(request.content) if hasattr(request, 'content') and request.content else 0,
|
|
119
|
+
},
|
|
120
|
+
"response": {
|
|
121
|
+
"status_code": response.status_code,
|
|
122
|
+
"headers": _redact_headers(response_headers),
|
|
123
|
+
"body": response_body,
|
|
124
|
+
"body_length": len(response.content) if hasattr(response, 'content') and response.content else 0,
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
with self._write_lock:
|
|
129
|
+
self._file.write(json.dumps(entry) + "\n")
|
|
130
|
+
self._file.flush()
|
|
131
|
+
|
|
132
|
+
def close(self):
|
|
133
|
+
"""Close the log file."""
|
|
134
|
+
with self._write_lock:
|
|
135
|
+
if self._file:
|
|
136
|
+
self._file.close()
|
|
137
|
+
self._file = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def install_httpx_hooks():
|
|
141
|
+
"""Install global httpx event hooks for traffic logging.
|
|
142
|
+
|
|
143
|
+
This patches httpx to log all HTTP traffic automatically.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
import httpx
|
|
147
|
+
except ImportError:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
logger = HttpTrafficLogger.get()
|
|
151
|
+
original_send = httpx.Client.send
|
|
152
|
+
original_async_send = httpx.AsyncClient.send
|
|
153
|
+
|
|
154
|
+
def patched_send(self, request, **kwargs):
|
|
155
|
+
request_id = logger.log_request(request)
|
|
156
|
+
response = original_send(self, request, **kwargs)
|
|
157
|
+
logger.log_response(request, response, request_id)
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
async def patched_async_send(self, request, **kwargs):
|
|
161
|
+
request_id = logger.log_request(request)
|
|
162
|
+
response = await original_async_send(self, request, **kwargs)
|
|
163
|
+
logger.log_response(request, response, request_id)
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
httpx.Client.send = patched_send
|
|
167
|
+
httpx.AsyncClient.send = patched_async_send
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def setup_logging(log_file: Optional[Path] = None):
|
|
171
|
+
"""Setup HTTP traffic logging.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
log_file: Path to JSONL log file. If None, creates timestamped file in ~/.fleet/proxy_logs/
|
|
175
|
+
"""
|
|
176
|
+
if log_file:
|
|
177
|
+
HttpTrafficLogger.set_log_file(log_file)
|
|
178
|
+
install_httpx_hooks()
|
fleet/utils/logging.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Logging utilities for Fleet SDK."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# Verbose logging flag - check once at import time
|
|
6
|
+
VERBOSE = os.environ.get("FLEET_VERBOSE", "false").lower() in ("true", "1", "yes")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def log_verbose(*args, **kwargs):
|
|
10
|
+
"""Print only if FLEET_VERBOSE is enabled."""
|
|
11
|
+
if VERBOSE:
|
|
12
|
+
print(*args, **kwargs)
|
|
13
|
+
|