traceforge-toolkit 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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""File polling source for append-only log files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from traceforge.sources.base import RawRecord, Source
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FilePollSource(Source):
|
|
20
|
+
"""Poll a file for new appended content at a fixed interval.
|
|
21
|
+
|
|
22
|
+
Detects truncation/rotation via size comparison and reopens from the
|
|
23
|
+
beginning. Suitable for files that are not watched via OS events (e.g.
|
|
24
|
+
network mounts where inotify is unavailable).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
path: str | Path,
|
|
30
|
+
name: str,
|
|
31
|
+
interval: float = 2.0,
|
|
32
|
+
encoding: str = "utf-8",
|
|
33
|
+
missing: Literal["wait", "error"] = "wait",
|
|
34
|
+
) -> None:
|
|
35
|
+
if interval < 0:
|
|
36
|
+
raise ValueError("interval must be non-negative")
|
|
37
|
+
self.path = Path(path).resolve()
|
|
38
|
+
self.name = name
|
|
39
|
+
self.interval = interval
|
|
40
|
+
self.encoding = encoding
|
|
41
|
+
self.missing = missing
|
|
42
|
+
self._offset = 0
|
|
43
|
+
self._buffer = ""
|
|
44
|
+
self._sequence = 0
|
|
45
|
+
self._iterating = False
|
|
46
|
+
self._inode: int | None = None
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> "FilePollSource":
|
|
49
|
+
if self.missing == "error" and not self.path.exists():
|
|
50
|
+
raise FileNotFoundError(f"FilePollSource target does not exist: {self.path}")
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
async def __aexit__(
|
|
54
|
+
self,
|
|
55
|
+
exc_type: type[BaseException] | None,
|
|
56
|
+
exc: BaseException | None,
|
|
57
|
+
tb: TracebackType | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._iterating = False
|
|
60
|
+
|
|
61
|
+
async def _iter_records(self) -> AsyncIterator[RawRecord]:
|
|
62
|
+
if self._iterating:
|
|
63
|
+
raise RuntimeError("FilePollSource does not support concurrent iteration")
|
|
64
|
+
self._iterating = True
|
|
65
|
+
try:
|
|
66
|
+
while True:
|
|
67
|
+
async for record in self._poll_once():
|
|
68
|
+
yield record
|
|
69
|
+
await asyncio.sleep(self.interval)
|
|
70
|
+
finally:
|
|
71
|
+
self._iterating = False
|
|
72
|
+
|
|
73
|
+
def __aiter__(self) -> AsyncIterator[RawRecord]:
|
|
74
|
+
return self._iter_records()
|
|
75
|
+
|
|
76
|
+
async def _poll_once(self) -> AsyncIterator[RawRecord]:
|
|
77
|
+
try:
|
|
78
|
+
stat = await asyncio.to_thread(self.path.stat)
|
|
79
|
+
except (FileNotFoundError, OSError):
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
current_inode = self._get_inode(stat)
|
|
83
|
+
rotated = self._inode is not None and current_inode != self._inode
|
|
84
|
+
truncated = stat.st_size < self._offset
|
|
85
|
+
|
|
86
|
+
if rotated or truncated:
|
|
87
|
+
logger.info("FilePollSource %s: file rotated/truncated, resetting", self.name)
|
|
88
|
+
self._offset = 0
|
|
89
|
+
self._buffer = ""
|
|
90
|
+
self._inode = current_inode
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
text = await asyncio.to_thread(self._read_from_offset)
|
|
94
|
+
except (FileNotFoundError, OSError):
|
|
95
|
+
self._offset = 0
|
|
96
|
+
self._buffer = ""
|
|
97
|
+
self._inode = None
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if not text:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
self._buffer += text
|
|
104
|
+
lines = self._buffer.splitlines(keepends=True)
|
|
105
|
+
if lines and not lines[-1].endswith(("\n", "\r")):
|
|
106
|
+
self._buffer = lines.pop()
|
|
107
|
+
else:
|
|
108
|
+
self._buffer = ""
|
|
109
|
+
|
|
110
|
+
for line in lines:
|
|
111
|
+
yield self._make_record(line.rstrip("\r\n"))
|
|
112
|
+
|
|
113
|
+
def _read_from_offset(self) -> str:
|
|
114
|
+
with self.path.open("r", encoding=self.encoding, errors="replace", newline="") as handle:
|
|
115
|
+
handle.seek(self._offset)
|
|
116
|
+
data = handle.read()
|
|
117
|
+
self._offset = handle.tell()
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _get_inode(stat: os.stat_result) -> int:
|
|
122
|
+
"""Get file identity for rotation detection."""
|
|
123
|
+
if stat.st_ino != 0:
|
|
124
|
+
return stat.st_ino
|
|
125
|
+
return hash((stat.st_dev, stat.st_ctime_ns))
|
|
126
|
+
|
|
127
|
+
def _make_record(self, payload: str) -> RawRecord:
|
|
128
|
+
record = RawRecord(
|
|
129
|
+
payload=payload,
|
|
130
|
+
source_name=self.name,
|
|
131
|
+
mode="poll",
|
|
132
|
+
sequence=self._sequence,
|
|
133
|
+
received_at=datetime.now(timezone.utc),
|
|
134
|
+
)
|
|
135
|
+
self._sequence += 1
|
|
136
|
+
return record
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""File watching source using watchdog for OS-native filesystem events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Literal, TextIO
|
|
12
|
+
|
|
13
|
+
from watchdog.events import (
|
|
14
|
+
FileCreatedEvent,
|
|
15
|
+
FileModifiedEvent,
|
|
16
|
+
FileMovedEvent,
|
|
17
|
+
FileSystemEventHandler,
|
|
18
|
+
)
|
|
19
|
+
from watchdog.observers import Observer
|
|
20
|
+
|
|
21
|
+
from traceforge.sources.base import RawRecord, Source
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FileHandler(FileSystemEventHandler):
|
|
25
|
+
"""Watchdog handler that signals on file creation, modification, or move."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, target_path: Path, loop: asyncio.AbstractEventLoop) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._target = str(target_path)
|
|
30
|
+
self._loop = loop
|
|
31
|
+
self._stopped = False
|
|
32
|
+
self.changed = asyncio.Event()
|
|
33
|
+
|
|
34
|
+
def stop(self) -> None:
|
|
35
|
+
self._stopped = True
|
|
36
|
+
|
|
37
|
+
def _signal(self) -> None:
|
|
38
|
+
if self._stopped:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
self._loop.call_soon_threadsafe(self.changed.set)
|
|
42
|
+
except RuntimeError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def on_modified(self, event: FileModifiedEvent) -> None: # type: ignore[override]
|
|
46
|
+
if event.is_directory:
|
|
47
|
+
return
|
|
48
|
+
if str(Path(event.src_path).resolve()) == self._target:
|
|
49
|
+
self._signal()
|
|
50
|
+
|
|
51
|
+
def on_created(self, event: FileCreatedEvent) -> None: # type: ignore[override]
|
|
52
|
+
if event.is_directory:
|
|
53
|
+
return
|
|
54
|
+
if str(Path(event.src_path).resolve()) == self._target:
|
|
55
|
+
self._signal()
|
|
56
|
+
|
|
57
|
+
def on_moved(self, event: FileMovedEvent) -> None: # type: ignore[override]
|
|
58
|
+
if event.is_directory:
|
|
59
|
+
return
|
|
60
|
+
if str(Path(event.dest_path).resolve()) == self._target:
|
|
61
|
+
self._signal()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FileWatchSource(Source):
|
|
65
|
+
"""Tail a file using watchdog OS-native events, yielding complete lines.
|
|
66
|
+
|
|
67
|
+
Handles file rotation, truncation, and creation. When the file is replaced
|
|
68
|
+
(new inode) or truncated, the source reopens from the beginning.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
path: str | Path,
|
|
74
|
+
name: str,
|
|
75
|
+
start_at: Literal["beginning", "end"] = "end",
|
|
76
|
+
encoding: str = "utf-8",
|
|
77
|
+
) -> None:
|
|
78
|
+
self.path = Path(path).resolve()
|
|
79
|
+
self.name = name
|
|
80
|
+
self.start_at = start_at
|
|
81
|
+
self.encoding = encoding
|
|
82
|
+
self._file: TextIO | None = None
|
|
83
|
+
self._buffer = ""
|
|
84
|
+
self._sequence = 0
|
|
85
|
+
self._observer: Observer | None = None
|
|
86
|
+
self._handler: _FileHandler | None = None
|
|
87
|
+
self._inode: int | None = None
|
|
88
|
+
self._last_size: int = 0
|
|
89
|
+
self._iterating = False
|
|
90
|
+
|
|
91
|
+
async def __aenter__(self) -> "FileWatchSource":
|
|
92
|
+
self._open_file(start_at=self.start_at)
|
|
93
|
+
loop = asyncio.get_running_loop()
|
|
94
|
+
self._handler = _FileHandler(self.path, loop)
|
|
95
|
+
self._observer = Observer()
|
|
96
|
+
self._observer.schedule(self._handler, str(self.path.parent), recursive=False)
|
|
97
|
+
self._observer.start()
|
|
98
|
+
# Signal immediately so _iter_records drains any pre-existing content
|
|
99
|
+
if self.start_at == "beginning" and self._file is not None:
|
|
100
|
+
self._handler.changed.set()
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
async def __aexit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: type[BaseException] | None,
|
|
106
|
+
exc: BaseException | None,
|
|
107
|
+
tb: TracebackType | None,
|
|
108
|
+
) -> None:
|
|
109
|
+
if self._handler is not None:
|
|
110
|
+
self._handler.stop()
|
|
111
|
+
if self._observer is not None:
|
|
112
|
+
self._observer.stop()
|
|
113
|
+
self._observer.join(timeout=5)
|
|
114
|
+
self._observer = None
|
|
115
|
+
if self._file is not None:
|
|
116
|
+
self._file.close()
|
|
117
|
+
self._file = None
|
|
118
|
+
self._buffer = ""
|
|
119
|
+
self._handler = None
|
|
120
|
+
self._iterating = False
|
|
121
|
+
|
|
122
|
+
async def _iter_records(self) -> AsyncIterator[RawRecord]:
|
|
123
|
+
if self._handler is None:
|
|
124
|
+
raise RuntimeError("FileWatchSource must be entered before iteration")
|
|
125
|
+
if self._iterating:
|
|
126
|
+
raise RuntimeError("FileWatchSource does not support concurrent iteration")
|
|
127
|
+
self._iterating = True
|
|
128
|
+
try:
|
|
129
|
+
while True:
|
|
130
|
+
await self._handler.changed.wait()
|
|
131
|
+
self._handler.changed.clear()
|
|
132
|
+
|
|
133
|
+
self._check_rotation()
|
|
134
|
+
|
|
135
|
+
if self._file is None:
|
|
136
|
+
self._open_file(start_at="beginning")
|
|
137
|
+
if self._file is None:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
chunk = self._file.read()
|
|
141
|
+
if not chunk:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
self._buffer += chunk
|
|
145
|
+
lines = self._buffer.splitlines(keepends=True)
|
|
146
|
+
if lines and not lines[-1].endswith(("\n", "\r")):
|
|
147
|
+
self._buffer = lines.pop()
|
|
148
|
+
else:
|
|
149
|
+
self._buffer = ""
|
|
150
|
+
|
|
151
|
+
for line in lines:
|
|
152
|
+
yield self._make_record(line.rstrip("\r\n"))
|
|
153
|
+
finally:
|
|
154
|
+
self._iterating = False
|
|
155
|
+
|
|
156
|
+
def __aiter__(self) -> AsyncIterator[RawRecord]:
|
|
157
|
+
return self._iter_records()
|
|
158
|
+
|
|
159
|
+
def _check_rotation(self) -> None:
|
|
160
|
+
"""Detect file rotation (new inode) or truncation and reopen."""
|
|
161
|
+
try:
|
|
162
|
+
stat = self.path.stat()
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
if self._file is not None:
|
|
165
|
+
self._file.close()
|
|
166
|
+
self._file = None
|
|
167
|
+
self._inode = None
|
|
168
|
+
self._last_size = 0
|
|
169
|
+
self._buffer = ""
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
current_inode = self._get_inode(stat)
|
|
173
|
+
truncated = stat.st_size < self._last_size
|
|
174
|
+
rotated = self._inode is not None and current_inode != self._inode
|
|
175
|
+
|
|
176
|
+
if truncated or rotated:
|
|
177
|
+
self._open_file(start_at="beginning")
|
|
178
|
+
else:
|
|
179
|
+
self._last_size = stat.st_size
|
|
180
|
+
|
|
181
|
+
def _open_file(self, start_at: Literal["beginning", "end"]) -> None:
|
|
182
|
+
if self._file is not None:
|
|
183
|
+
self._file.close()
|
|
184
|
+
self._file = None
|
|
185
|
+
try:
|
|
186
|
+
self._file = self.path.open("r", encoding=self.encoding, errors="replace", newline="")
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
self._file = None
|
|
189
|
+
self._inode = None
|
|
190
|
+
self._last_size = 0
|
|
191
|
+
return
|
|
192
|
+
if start_at == "end":
|
|
193
|
+
self._file.seek(0, 2)
|
|
194
|
+
else:
|
|
195
|
+
self._file.seek(0)
|
|
196
|
+
self._buffer = ""
|
|
197
|
+
try:
|
|
198
|
+
stat = self.path.stat()
|
|
199
|
+
self._inode = self._get_inode(stat)
|
|
200
|
+
self._last_size = stat.st_size
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
self._inode = None
|
|
203
|
+
self._last_size = 0
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _get_inode(stat: os.stat_result) -> int:
|
|
207
|
+
"""Get file identity. On Windows st_ino may be 0; fall back to creation time."""
|
|
208
|
+
if stat.st_ino != 0:
|
|
209
|
+
return stat.st_ino
|
|
210
|
+
return hash((stat.st_dev, stat.st_ctime_ns))
|
|
211
|
+
|
|
212
|
+
def _make_record(self, payload: str) -> RawRecord:
|
|
213
|
+
record = RawRecord(
|
|
214
|
+
payload=payload,
|
|
215
|
+
source_name=self.name,
|
|
216
|
+
mode="file_watch",
|
|
217
|
+
sequence=self._sequence,
|
|
218
|
+
received_at=datetime.now(timezone.utc),
|
|
219
|
+
)
|
|
220
|
+
self._sequence += 1
|
|
221
|
+
return record
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""HTTP polling source with conditional request support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from traceforge.sources.base import RawRecord, Source
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpPollSource(Source):
|
|
19
|
+
"""Poll an HTTP endpoint using conditional requests (ETag/Last-Modified).
|
|
20
|
+
|
|
21
|
+
Emits the full response body as a single record each time new content
|
|
22
|
+
is available. Supports cursor-based pagination via a custom header.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
url: str,
|
|
28
|
+
name: str,
|
|
29
|
+
interval: float = 10.0,
|
|
30
|
+
cursor_header: str | None = None,
|
|
31
|
+
headers: dict[str, str] | None = None,
|
|
32
|
+
max_retries: int = 3,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.url = url
|
|
35
|
+
self.name = name
|
|
36
|
+
self.interval = interval
|
|
37
|
+
self.cursor_header = cursor_header
|
|
38
|
+
self.headers = dict(headers or {})
|
|
39
|
+
self.max_retries = max_retries
|
|
40
|
+
self._etag: str | None = None
|
|
41
|
+
self._last_modified: str | None = None
|
|
42
|
+
self._cursor: str | None = None
|
|
43
|
+
self._sequence = 0
|
|
44
|
+
self._client: httpx.AsyncClient | None = None
|
|
45
|
+
self._iterating = False
|
|
46
|
+
|
|
47
|
+
async def __aenter__(self) -> "HttpPollSource":
|
|
48
|
+
self._client = httpx.AsyncClient()
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(
|
|
52
|
+
self,
|
|
53
|
+
exc_type: type[BaseException] | None,
|
|
54
|
+
exc: BaseException | None,
|
|
55
|
+
tb: TracebackType | None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if self._client is not None:
|
|
58
|
+
await self._client.aclose()
|
|
59
|
+
self._client = None
|
|
60
|
+
self._iterating = False
|
|
61
|
+
|
|
62
|
+
async def _iter_records(self) -> AsyncIterator[RawRecord]:
|
|
63
|
+
if self._client is None:
|
|
64
|
+
raise RuntimeError("HttpPollSource must be entered before iteration")
|
|
65
|
+
if self._iterating:
|
|
66
|
+
raise RuntimeError("HttpPollSource does not support concurrent iteration")
|
|
67
|
+
self._iterating = True
|
|
68
|
+
try:
|
|
69
|
+
while True:
|
|
70
|
+
record = await self._poll_once()
|
|
71
|
+
if record is not None:
|
|
72
|
+
yield record
|
|
73
|
+
await asyncio.sleep(self.interval)
|
|
74
|
+
finally:
|
|
75
|
+
self._iterating = False
|
|
76
|
+
|
|
77
|
+
def __aiter__(self) -> AsyncIterator[RawRecord]:
|
|
78
|
+
return self._iter_records()
|
|
79
|
+
|
|
80
|
+
async def _poll_once(self) -> RawRecord | None:
|
|
81
|
+
"""Execute one poll cycle with retry on transient errors."""
|
|
82
|
+
for attempt in range(self.max_retries + 1):
|
|
83
|
+
try:
|
|
84
|
+
return await self._do_request()
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
raise
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
if attempt >= self.max_retries:
|
|
89
|
+
logger.error(
|
|
90
|
+
"HttpPollSource %s: poll failed after %d retries: %s",
|
|
91
|
+
self.name,
|
|
92
|
+
self.max_retries,
|
|
93
|
+
exc,
|
|
94
|
+
)
|
|
95
|
+
return None
|
|
96
|
+
delay = min(2**attempt, 30)
|
|
97
|
+
logger.warning(
|
|
98
|
+
"HttpPollSource %s: transient error (attempt %d/%d): %s",
|
|
99
|
+
self.name,
|
|
100
|
+
attempt + 1,
|
|
101
|
+
self.max_retries,
|
|
102
|
+
exc,
|
|
103
|
+
)
|
|
104
|
+
await asyncio.sleep(delay)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
async def _do_request(self) -> RawRecord | None:
|
|
108
|
+
assert self._client is not None
|
|
109
|
+
headers = dict(self.headers)
|
|
110
|
+
if self._etag:
|
|
111
|
+
headers["If-None-Match"] = self._etag
|
|
112
|
+
if self._last_modified:
|
|
113
|
+
headers["If-Modified-Since"] = self._last_modified
|
|
114
|
+
if self.cursor_header and self._cursor:
|
|
115
|
+
headers[self.cursor_header] = self._cursor
|
|
116
|
+
|
|
117
|
+
response = await self._client.get(self.url, headers=headers)
|
|
118
|
+
if response.status_code == 304:
|
|
119
|
+
return None
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
|
|
122
|
+
self._etag = response.headers.get("ETag", self._etag)
|
|
123
|
+
self._last_modified = response.headers.get("Last-Modified", self._last_modified)
|
|
124
|
+
if self.cursor_header:
|
|
125
|
+
self._cursor = response.headers.get(self.cursor_header, self._cursor)
|
|
126
|
+
|
|
127
|
+
text = response.text
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
return self._make_record(text)
|
|
131
|
+
|
|
132
|
+
def _make_record(self, payload: str) -> RawRecord:
|
|
133
|
+
record = RawRecord(
|
|
134
|
+
payload=payload,
|
|
135
|
+
source_name=self.name,
|
|
136
|
+
mode="poll",
|
|
137
|
+
sequence=self._sequence,
|
|
138
|
+
received_at=datetime.now(timezone.utc),
|
|
139
|
+
)
|
|
140
|
+
self._sequence += 1
|
|
141
|
+
return record
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Finite replay source for reading an existing file from start to end."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
from traceforge.sources.base import RawRecord, Source
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReplaySource(Source):
|
|
15
|
+
"""Replay a file line-by-line from beginning to end.
|
|
16
|
+
|
|
17
|
+
Reads the file in a background thread to avoid blocking the event loop.
|
|
18
|
+
Validates that the file exists on entry.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: str | Path, name: str, encoding: str = "utf-8") -> None:
|
|
22
|
+
self.path = Path(path)
|
|
23
|
+
self.name = name
|
|
24
|
+
self.encoding = encoding
|
|
25
|
+
self._iterating = False
|
|
26
|
+
|
|
27
|
+
async def __aenter__(self) -> "ReplaySource":
|
|
28
|
+
if not self.path.exists():
|
|
29
|
+
raise FileNotFoundError(f"ReplaySource target does not exist: {self.path}")
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
async def __aexit__(
|
|
33
|
+
self,
|
|
34
|
+
exc_type: type[BaseException] | None,
|
|
35
|
+
exc: BaseException | None,
|
|
36
|
+
tb: TracebackType | None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._iterating = False
|
|
39
|
+
|
|
40
|
+
async def _iter_records(self) -> AsyncIterator[RawRecord]:
|
|
41
|
+
if self._iterating:
|
|
42
|
+
raise RuntimeError("ReplaySource does not support concurrent iteration")
|
|
43
|
+
self._iterating = True
|
|
44
|
+
try:
|
|
45
|
+
lines = await asyncio.to_thread(self._read_lines)
|
|
46
|
+
for index, line in enumerate(lines):
|
|
47
|
+
yield RawRecord(
|
|
48
|
+
payload=line,
|
|
49
|
+
source_name=self.name,
|
|
50
|
+
mode="replay",
|
|
51
|
+
sequence=index,
|
|
52
|
+
received_at=datetime.now(timezone.utc),
|
|
53
|
+
)
|
|
54
|
+
finally:
|
|
55
|
+
self._iterating = False
|
|
56
|
+
|
|
57
|
+
def __aiter__(self) -> AsyncIterator[RawRecord]:
|
|
58
|
+
return self._iter_records()
|
|
59
|
+
|
|
60
|
+
def _read_lines(self) -> list[str]:
|
|
61
|
+
"""Read file lines in a background thread."""
|
|
62
|
+
with self.path.open("r", encoding=self.encoding) as fh:
|
|
63
|
+
return [line.rstrip("\r\n") for line in fh]
|