Skip to content

Commit 73f1555

Browse files
authored
Create jsonl.py
1 parent cd5bcd0 commit 73f1555

1 file changed

Lines changed: 174 additions & 0 deletions

File tree

src/ohip_logging/jsonl.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""
2+
JSONL event logging utilities for IX-HapticSight.
3+
4+
This module provides a simple, backend-agnostic structured event log format
5+
based on newline-delimited JSON. The format is intended to support:
6+
7+
- runtime audit trails
8+
- replay inputs
9+
- benchmark artifacts
10+
- deterministic debugging bundles
11+
12+
Design goals:
13+
- append-friendly
14+
- human-inspectable
15+
- stable enough for replay and tests
16+
- no hidden console-only behavior
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
from pathlib import Path
23+
from typing import Iterable, Iterator, Optional
24+
25+
from .events import EventRecord
26+
27+
28+
class EventLogWriter:
29+
"""
30+
Append-oriented JSONL writer for structured event records.
31+
32+
Each line is one serialized EventRecord dictionary.
33+
34+
This class intentionally avoids buffering large in-memory structures.
35+
It is meant to be simple and predictable for local runtime logs,
36+
benchmark outputs, and replay artifacts.
37+
"""
38+
39+
def __init__(self, path: str | Path) -> None:
40+
self._path = Path(path)
41+
42+
@property
43+
def path(self) -> Path:
44+
return self._path
45+
46+
def ensure_parent_dir(self) -> None:
47+
self._path.parent.mkdir(parents=True, exist_ok=True)
48+
49+
def append(self, event: EventRecord) -> None:
50+
"""
51+
Append a single event record to the log.
52+
"""
53+
self.ensure_parent_dir()
54+
with self._path.open("a", encoding="utf-8") as handle:
55+
handle.write(_encode_event(event))
56+
handle.write("\n")
57+
58+
def append_many(self, events: Iterable[EventRecord]) -> int:
59+
"""
60+
Append multiple events and return the number written.
61+
"""
62+
self.ensure_parent_dir()
63+
written = 0
64+
with self._path.open("a", encoding="utf-8") as handle:
65+
for event in events:
66+
handle.write(_encode_event(event))
67+
handle.write("\n")
68+
written += 1
69+
return written
70+
71+
def exists(self) -> bool:
72+
return self._path.exists()
73+
74+
def read_all(self) -> list[EventRecord]:
75+
return list(iter_event_log(self._path))
76+
77+
def clear(self) -> None:
78+
"""
79+
Remove all content from the log file, preserving the file path.
80+
"""
81+
self.ensure_parent_dir()
82+
self._path.write_text("", encoding="utf-8")
83+
84+
85+
def iter_event_log(path: str | Path) -> Iterator[EventRecord]:
86+
"""
87+
Iterate over a JSONL event log.
88+
89+
Blank lines are ignored.
90+
"""
91+
log_path = Path(path)
92+
if not log_path.exists():
93+
return iter(())
94+
return _iter_event_lines(log_path)
95+
96+
97+
def load_event_log(path: str | Path) -> list[EventRecord]:
98+
"""
99+
Load all events from a JSONL event log.
100+
"""
101+
return list(iter_event_log(path))
102+
103+
104+
def write_event_log(path: str | Path, events: Iterable[EventRecord]) -> int:
105+
"""
106+
Overwrite a log file with exactly the provided event sequence.
107+
Returns the number of written events.
108+
"""
109+
log_path = Path(path)
110+
log_path.parent.mkdir(parents=True, exist_ok=True)
111+
112+
written = 0
113+
with log_path.open("w", encoding="utf-8") as handle:
114+
for event in events:
115+
handle.write(_encode_event(event))
116+
handle.write("\n")
117+
written += 1
118+
return written
119+
120+
121+
def tail_event_log(path: str | Path, limit: int = 20) -> list[EventRecord]:
122+
"""
123+
Return the last N events from a log.
124+
125+
This implementation reads the full file for simplicity and correctness.
126+
That is acceptable for the small-to-moderate artifact sizes expected in
127+
this repository stage.
128+
"""
129+
if limit <= 0:
130+
return []
131+
events = load_event_log(path)
132+
return events[-limit:]
133+
134+
135+
def last_event(path: str | Path) -> Optional[EventRecord]:
136+
"""
137+
Return the last event in a log or None if the file is missing or empty.
138+
"""
139+
events = tail_event_log(path, limit=1)
140+
return events[0] if events else None
141+
142+
143+
def _iter_event_lines(path: Path) -> Iterator[EventRecord]:
144+
with path.open("r", encoding="utf-8") as handle:
145+
for line_number, line in enumerate(handle, start=1):
146+
text = line.strip()
147+
if not text:
148+
continue
149+
try:
150+
payload = json.loads(text)
151+
except json.JSONDecodeError as exc:
152+
raise ValueError(f"invalid JSON in event log at line {line_number}: {path}") from exc
153+
if not isinstance(payload, dict):
154+
raise ValueError(f"event log line {line_number} is not a JSON object: {path}")
155+
yield EventRecord.from_dict(payload)
156+
157+
158+
def _encode_event(event: EventRecord) -> str:
159+
return json.dumps(
160+
event.to_dict(),
161+
sort_keys=True,
162+
separators=(",", ":"),
163+
ensure_ascii=False,
164+
)
165+
166+
167+
__all__ = [
168+
"EventLogWriter",
169+
"iter_event_log",
170+
"load_event_log",
171+
"write_event_log",
172+
"tail_event_log",
173+
"last_event",
174+
]

0 commit comments

Comments
 (0)