fullstop 0.2.1__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.
fullstop/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """fullstop — a zero-dependency Python runtime for always-on agents.
2
+
3
+ Final name (owner ruling 2026-09-30; verified free on PyPI that day). This
4
+ package deliberately exports nothing but its version; import submodules
5
+ directly.
6
+ """
7
+
8
+ __version__: str = "0.2.1"
fullstop/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m fullstop`` entry point."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
fullstop/activity.py ADDED
@@ -0,0 +1,241 @@
1
+ """Append-only, tamper-evident JSONL activity log.
2
+
3
+ Every line carries a chained hash: sha256(f"{prev}:{canonical_json(line_without_hash)}").
4
+ String values are scrubbed and truncated AT WRITE TIME, so credentials never
5
+ reach disk even in model replies.
6
+
7
+ v0.1.1 (FIXLIST item 8): appends are O(1) — they chain from the cached
8
+ last-line hash and never re-read the whole file (a bounded tail-window read
9
+ still validates the last line on every append, so an externally corrupted or
10
+ swapped tail is still refused). When the live segment grows past
11
+ ``max_segment_bytes`` it rotates into a timestamped segment in the same
12
+ directory; the chain and the global ``seq`` CONTINUE across segments, and
13
+ ``verify()``/``entries()``/``tail()`` walk all segments in order.
14
+ """
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Iterator
23
+
24
+ from .redact import Redactor, truncate
25
+
26
+ _RESERVED = {"seq", "ts", "event", "prev", "hash"}
27
+ # How far back from EOF the bounded tail read may look for the last line.
28
+ _TAIL_WINDOW_BYTES = 1 << 20
29
+ _SEGMENT_RE = re.compile(r"^activity-\d{8}T\d{6,12}Z-(\d+)\.jsonl$")
30
+
31
+
32
+ def _canonical(obj: Any) -> str:
33
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
34
+
35
+
36
+ class ActivityLogError(RuntimeError):
37
+ pass
38
+
39
+
40
+ class ActivityLog:
41
+ def __init__(self, path: Path, redactor: Redactor | None = None,
42
+ truncate_chars: int = 2000,
43
+ max_segment_bytes: int = 1_048_576) -> None:
44
+ self._path = Path(path)
45
+ self._path.parent.mkdir(parents=True, exist_ok=True)
46
+ self._redactor = redactor
47
+ self._truncate_chars = truncate_chars
48
+ self._max_segment_bytes = max(1024, int(max_segment_bytes))
49
+ # Chain head cache: {"hash": ..., "seq": ...} of the last appended
50
+ # (or loaded) entry. O(1) appends chain from this, never from a
51
+ # full-file re-read.
52
+ self._tail: dict[str, Any] | None = None
53
+ self._tail_loaded = False
54
+
55
+ # -- scrubbing -----------------------------------------------------------
56
+
57
+ def _scrub(self, value: Any) -> Any:
58
+ if isinstance(value, str):
59
+ if self._redactor is not None:
60
+ value = self._redactor.scrub(value)
61
+ return truncate(value, self._truncate_chars)
62
+ if isinstance(value, list):
63
+ return [self._scrub(v) for v in value]
64
+ if isinstance(value, dict):
65
+ out = {}
66
+ for k, v in value.items():
67
+ key = self._redactor.scrub(str(k)) if self._redactor else str(k)
68
+ out[key] = self._scrub(v)
69
+ return out
70
+ return value
71
+
72
+ # -- ordered segments ------------------------------------------------------
73
+
74
+ def _segment_files(self) -> list[Path]:
75
+ """All segment files in CHAIN order: rotated segments sorted by the
76
+ seq stamped in their name, then the live file."""
77
+ rotated: list[tuple[int, Path]] = []
78
+ if self._path.parent.exists():
79
+ for p in self._path.parent.glob("activity-*.jsonl"):
80
+ m = _SEGMENT_RE.match(p.name)
81
+ if m:
82
+ rotated.append((int(m.group(1)), p))
83
+ rotated.sort(key=lambda sp: sp[0])
84
+ return [p for _, p in rotated] + [self._path]
85
+
86
+ def _read_last_entry(self) -> dict:
87
+ """The last chain entry across segments, via a BOUNDED tail read.
88
+ Raises ActivityLogError on a corrupt or unparseable tail; returns
89
+ {} when nothing has been logged yet.
90
+
91
+ v0.1.2 (FIXLIST2 item 3): a line larger than the tail window makes
92
+ the window start MID-LINE, and the fragment it yields is not a
93
+ valid entry — which used to wedge every future append. When the
94
+ window read cannot produce a valid last line, the segment is
95
+ re-read IN FULL (pathological case only; normal appends never hit
96
+ it) so the real last line can be validated. A genuinely corrupt or
97
+ tampered tail still raises — from the full read."""
98
+ for path in reversed(self._segment_files()):
99
+ if not path.exists():
100
+ continue
101
+ size = path.stat().st_size
102
+ if size == 0:
103
+ continue
104
+ with path.open("rb") as fh:
105
+ window = min(size, _TAIL_WINDOW_BYTES)
106
+ fh.seek(-window, os.SEEK_END)
107
+ data = fh.read()
108
+ lines = [ln for ln in
109
+ data.decode("utf-8", errors="replace").split("\n")
110
+ if ln.strip()]
111
+ try:
112
+ obj = self._validate_last_line(lines)
113
+ except ActivityLogError:
114
+ if window >= size:
115
+ raise # the window already saw the whole segment
116
+ full_lines = [ln for ln in
117
+ path.read_text(encoding="utf-8",
118
+ errors="replace").split("\n")
119
+ if ln.strip()]
120
+ return self._validate_last_line(full_lines)
121
+ if obj is not None:
122
+ return obj
123
+ return {}
124
+
125
+ @staticmethod
126
+ def _validate_last_line(lines: list[str]) -> dict | None:
127
+ """Validate ``lines[-1]`` as the chain tail: None when the segment
128
+ has no lines, the entry dict when valid; raises ActivityLogError on
129
+ an unparseable, chain-field-missing, or hash-failing tail."""
130
+ if not lines:
131
+ return None
132
+ line = lines[-1]
133
+ try:
134
+ obj = json.loads(line)
135
+ except json.JSONDecodeError as e:
136
+ raise ActivityLogError(
137
+ f"activity log tail is not valid JSON: {e.msg}") from e
138
+ if (not isinstance(obj, dict) or "seq" not in obj
139
+ or "prev" not in obj or "hash" not in obj):
140
+ raise ActivityLogError(
141
+ "activity log tail is missing chain fields")
142
+ expected = _sha256(
143
+ f'{obj["prev"]}:{_canonical({k: v for k, v in obj.items() if k != "hash"})}')
144
+ if expected != obj["hash"]:
145
+ raise ActivityLogError(
146
+ "activity log tail fails its chained hash (corrupt or "
147
+ "tampered); refusing to extend")
148
+ return obj
149
+
150
+ def _load_tail(self) -> None:
151
+ obj = self._read_last_entry()
152
+ if obj:
153
+ self._tail = {"hash": obj["hash"], "seq": obj["seq"]}
154
+ else:
155
+ self._tail = {"hash": "0", "seq": 0}
156
+ self._tail_loaded = True
157
+
158
+ # -- append --------------------------------------------------------------------
159
+
160
+ def append(self, event: str, **fields: Any) -> str:
161
+ clash = _RESERVED & set(fields)
162
+ if clash:
163
+ raise ValueError(f"reserved field names cannot be logged: {sorted(clash)}")
164
+ if not self._tail_loaded:
165
+ self._load_tail()
166
+ else:
167
+ # Cheap tamper tripwire: re-read ONLY the last line (bounded
168
+ # window) and refuse to extend if it moved under us.
169
+ current = self._read_last_entry()
170
+ if (not current
171
+ or current.get("hash") != self._tail["hash"]
172
+ or current.get("seq") != self._tail["seq"]):
173
+ raise ActivityLogError(
174
+ "activity log tail changed externally since the last "
175
+ "append (corrupt or tampered); refusing to extend")
176
+ entry: dict[str, Any] = {
177
+ "seq": self._tail["seq"] + 1,
178
+ "ts": datetime.now(timezone.utc).isoformat(),
179
+ "event": event,
180
+ }
181
+ for key, value in fields.items():
182
+ entry[key] = self._scrub(value)
183
+ entry["prev"] = self._tail["hash"]
184
+ entry["hash"] = _sha256(f'{entry["prev"]}:{_canonical(entry)}')
185
+ line = _canonical(entry)
186
+ # Size-based rotation: move the full live segment aside and start a
187
+ # fresh one; the chain and seq continue across the boundary.
188
+ if self._path.exists() and self._path.stat().st_size > 0:
189
+ projected = self._path.stat().st_size + len(line) + 1
190
+ if projected > self._max_segment_bytes:
191
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + "Z"
192
+ rotated = self._path.with_name(
193
+ f"activity-{stamp}-{self._tail['seq']}.jsonl")
194
+ os.replace(self._path, rotated)
195
+ with self._path.open("a", encoding="utf-8") as fh:
196
+ fh.write(line + "\n")
197
+ self._tail = {"hash": entry["hash"], "seq": entry["seq"]}
198
+ return entry["hash"]
199
+
200
+ # -- reading ----------------------------------------------------------------------
201
+
202
+ def entries(self) -> Iterator[dict]:
203
+ for path in self._segment_files():
204
+ if not path.exists():
205
+ continue
206
+ for line in path.read_text(encoding="utf-8").split("\n"):
207
+ if line.strip():
208
+ yield json.loads(line)
209
+
210
+ def tail(self, n: int) -> list[dict]:
211
+ return list(self.entries())[-n:]
212
+
213
+ def verify(self) -> tuple[bool, int | None]:
214
+ """Walk the whole chain across ALL segments; returns (ok, first_bad_seq
215
+ or None)."""
216
+ prev = "0"
217
+ expected_seq = 1
218
+ for path in self._segment_files():
219
+ if not path.exists():
220
+ continue
221
+ for line in path.read_text(encoding="utf-8").split("\n"):
222
+ if not line.strip():
223
+ continue
224
+ try:
225
+ obj = json.loads(line)
226
+ except json.JSONDecodeError:
227
+ return False, expected_seq
228
+ if not isinstance(obj, dict):
229
+ return False, expected_seq
230
+ if obj.get("seq") != expected_seq or obj.get("prev") != prev:
231
+ return False, expected_seq
232
+ body = {k: v for k, v in obj.items() if k != "hash"}
233
+ if _sha256(f'{obj.get("prev")}:{_canonical(body)}') != obj.get("hash"):
234
+ return False, expected_seq
235
+ prev = obj.get("hash")
236
+ expected_seq += 1
237
+ return True, None
238
+
239
+
240
+ def _sha256(text: str) -> str:
241
+ return hashlib.sha256(text.encode()).hexdigest()