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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. 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]