lightlogger 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.
@@ -0,0 +1,249 @@
1
+ """lightlogger: a live web dashboard for your Python logs. Zero dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ import uuid
9
+ import webbrowser
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from typing import Any
13
+
14
+ from lightlogger.buffer import LogBuffer, LogRecord, _current_group_id, capture_caller
15
+ from lightlogger.handler import LightloggerHandler
16
+ from lightlogger.server import LightloggerServer, create_server, serve_in_background
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "start",
22
+ "stop",
23
+ "debug",
24
+ "info",
25
+ "warn",
26
+ "error",
27
+ "var",
28
+ "request",
29
+ "group",
30
+ "help",
31
+ ]
32
+
33
+ _HELP_TEXT = """\
34
+ lightlogger -- live web dashboard for your Python logs
35
+ ========================================================
36
+
37
+ pip install lightlogger
38
+
39
+ Quickstart
40
+ ----------
41
+ import lightlogger
42
+ lightlogger.start()
43
+
44
+ That's it. Open the printed URL (default http://127.0.0.1:4356) and watch
45
+ your logs stream in live, in a dark, searchable dashboard.
46
+
47
+ Public API
48
+ ----------
49
+
50
+ start(port=4356, host="127.0.0.1", max_logs=5000,
51
+ capture_logging=True, open_browser=False)
52
+ Starts the dashboard server on a background thread. Calling it again
53
+ while already running is a no-op, not an error.
54
+ lightlogger.start(port=8080, open_browser=True)
55
+
56
+ stop()
57
+ Stops the server and detaches the logging handler. Mostly useful for
58
+ tests and notebooks -- most scripts never need to call this.
59
+ lightlogger.stop()
60
+
61
+ debug(msg, data=None)
62
+ Logs a debug-level message (grey in the UI).
63
+ lightlogger.debug("cache miss", data={"key": "user:42"})
64
+
65
+ info(msg, data=None)
66
+ Logs an info-level message (blue in the UI).
67
+ lightlogger.info("user logged in")
68
+
69
+ warn(msg, data=None)
70
+ Logs a warn-level message (yellow in the UI).
71
+ lightlogger.warn("retrying after timeout")
72
+
73
+ error(msg, data=None)
74
+ Logs an error-level message (red in the UI).
75
+ lightlogger.error("payment failed", data={"order_id": 123})
76
+
77
+ var(name, value)
78
+ Logs any variable as an expandable JSON blob under `name`.
79
+ lightlogger.var("cart", cart_dict)
80
+
81
+ request(method, url, status, duration_ms)
82
+ Logs one HTTP request/response as a single formatted line.
83
+ lightlogger.request("GET", "/api/users", 200, 12.4)
84
+
85
+ group(name)
86
+ Context manager that groups related log lines into a collapsible
87
+ tree in the UI. Nests, and is safe across threads and asyncio tasks
88
+ (each gets its own independent group context).
89
+ with lightlogger.group("process_order #4821"):
90
+ lightlogger.info("validating cart")
91
+ lightlogger.info("charging payment", data={"amount": 49.99})
92
+ with lightlogger.group("send_notifications"):
93
+ lightlogger.info("email sent")
94
+
95
+ Capturing stdlib `logging`
96
+ ---------------------------
97
+ start(capture_logging=True) is the default: it attaches a handler to the
98
+ root logger, so your existing `logging` calls -- and third-party
99
+ libraries' -- appear in the dashboard automatically, with zero code
100
+ changes.
101
+
102
+ Gotcha: Python's root logger defaults to level WARNING. A plain
103
+ `logging.info(...)` call will NOT appear unless your app has already
104
+ raised the level itself, e.g. `logging.basicConfig(level=logging.INFO)`.
105
+ lightlogger deliberately never forces the root logger's level open --
106
+ doing so would also unmute your app's OTHER existing handlers, which is
107
+ more disruptive than "zero code changes" is meant to be. `.warning()` and
108
+ above always show up out of the box; `.debug()`/`.info()` need that one
109
+ extra line if you want them too.
110
+
111
+ Not for production
112
+ -------------------
113
+ Binds to 127.0.0.1 only by default. This is a local development tool, not
114
+ a production observability system.
115
+
116
+ Docs & source
117
+ -------------
118
+ https://github.com/Rahuwale123/lightlogger
119
+ """
120
+
121
+ _buffer = LogBuffer(maxlen=5000)
122
+ _httpd: LightloggerServer | None = None
123
+ _thread: threading.Thread | None = None
124
+ _logging_handler: LightloggerHandler | None = None
125
+
126
+
127
+ def _emit(level: str, message: str, data: Any = None, *, logger_name: str = "lightlogger") -> None:
128
+ # skip=3: capture_caller's own frame, this frame, and the public
129
+ # debug/info/warn/error/var/request wrapper -> lands on the user's call site.
130
+ file, line = capture_caller(skip=3)
131
+ record: LogRecord = {
132
+ "time": time.time(),
133
+ "level": level,
134
+ "message": message,
135
+ "data": data,
136
+ "file": file,
137
+ "line": line,
138
+ "logger_name": logger_name,
139
+ "group_id": None,
140
+ "parent_group_id": _current_group_id.get(),
141
+ }
142
+ _buffer.add(record)
143
+
144
+
145
+ def start(
146
+ port: int = 4356,
147
+ host: str = "127.0.0.1",
148
+ max_logs: int = 5000,
149
+ capture_logging: bool = True,
150
+ open_browser: bool = False,
151
+ ) -> None:
152
+ global _httpd, _thread, _logging_handler
153
+ if _httpd is not None:
154
+ return # already running; start() is idempotent, not an error
155
+
156
+ _buffer.set_maxlen(max_logs)
157
+
158
+ if host == "0.0.0.0": # noqa: S104
159
+ print(
160
+ "lightlogger WARNING: binding to 0.0.0.0 exposes your logs to your "
161
+ "whole network. Only do this if you mean it."
162
+ )
163
+
164
+ _httpd = create_server(_buffer, host, port)
165
+ _thread = serve_in_background(_httpd)
166
+ bound_port = _httpd.server_address[1]
167
+ print(f"lightlogger UI → http://{host}:{bound_port}")
168
+
169
+ if open_browser:
170
+ webbrowser.open(f"http://{host}:{bound_port}")
171
+
172
+ if capture_logging:
173
+ _logging_handler = LightloggerHandler(_buffer)
174
+ logging.getLogger().addHandler(_logging_handler)
175
+
176
+
177
+ def stop() -> None:
178
+ global _httpd, _thread, _logging_handler
179
+ if _logging_handler is not None:
180
+ logging.getLogger().removeHandler(_logging_handler)
181
+ _logging_handler = None
182
+ if _httpd is None:
183
+ return
184
+ _httpd.shutdown()
185
+ _httpd.server_close()
186
+ _httpd = None
187
+ _thread = None
188
+
189
+
190
+ def debug(msg: str, data: Any | None = None) -> None:
191
+ _emit("debug", msg, data)
192
+
193
+
194
+ def info(msg: str, data: Any | None = None) -> None:
195
+ _emit("info", msg, data)
196
+
197
+
198
+ def warn(msg: str, data: Any | None = None) -> None:
199
+ _emit("warn", msg, data)
200
+
201
+
202
+ def error(msg: str, data: Any | None = None) -> None:
203
+ _emit("error", msg, data)
204
+
205
+
206
+ def var(name: str, value: Any) -> None:
207
+ _emit("info", name, value)
208
+
209
+
210
+ def request(method: str, url: str, status: int, duration_ms: float) -> None:
211
+ _emit(
212
+ "info",
213
+ f"{method} {url} {status} {duration_ms:.1f}ms",
214
+ {"method": method, "url": url, "status": status, "duration_ms": duration_ms},
215
+ )
216
+
217
+
218
+ def help() -> None:
219
+ """Print a quick-reference cheatsheet of the public API to stdout."""
220
+ print(_HELP_TEXT)
221
+
222
+
223
+ @contextmanager
224
+ def group(name: str) -> Iterator[None]:
225
+ group_id = uuid.uuid4().hex
226
+ parent_group_id = _current_group_id.get()
227
+ # skip=3: capture_caller's own frame, this generator's frame (resumed by
228
+ # @contextmanager's __enter__ via next()), and contextlib's
229
+ # _GeneratorContextManager.__enter__ itself -> lands on the user's
230
+ # `with lightlogger.group(...):` call site. Verified empirically in
231
+ # tests/test_buffer.py (test_group_captures_caller_file_and_line).
232
+ file, line = capture_caller(skip=3)
233
+ record: LogRecord = {
234
+ "time": time.time(),
235
+ "level": "group",
236
+ "message": name,
237
+ "data": None,
238
+ "file": file,
239
+ "line": line,
240
+ "logger_name": "lightlogger",
241
+ "group_id": group_id,
242
+ "parent_group_id": parent_group_id,
243
+ }
244
+ _buffer.add(record)
245
+ token = _current_group_id.set(group_id)
246
+ try:
247
+ yield
248
+ finally:
249
+ _current_group_id.reset(token)
lightlogger/buffer.py ADDED
@@ -0,0 +1,89 @@
1
+ """Ring buffer and SSE client fan-out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import sys
7
+ import threading
8
+ from collections import deque
9
+ from queue import Queue
10
+ from typing import Any, TypedDict
11
+
12
+
13
+ class LogRecord(TypedDict):
14
+ time: float
15
+ level: str
16
+ message: str
17
+ data: Any
18
+ file: str
19
+ line: int
20
+ logger_name: str
21
+ group_id: str | None
22
+ parent_group_id: str | None
23
+
24
+
25
+ def capture_caller(skip: int) -> tuple[str, int]:
26
+ # sys._getframe, not inspect.stack(): inspect.stack() reads source files
27
+ # off disk on every call, which is too slow for a logging hot path.
28
+ frame = sys._getframe(skip)
29
+ return frame.f_code.co_filename, frame.f_lineno
30
+
31
+
32
+ # Holds "the current group id" per thread/async-task, not a plain module
33
+ # global (which would leak across threads). Lives here rather than in
34
+ # __init__.py because handler.py needs it too (so plain logging.info() calls
35
+ # made inside a group() block nest correctly), and handler.py already
36
+ # imports from buffer.py -- importing from __init__.py instead would risk a
37
+ # circular import.
38
+ _current_group_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
39
+ "lightlogger_current_group_id", default=None
40
+ )
41
+
42
+
43
+ class LogBuffer:
44
+ """Bounded ring buffer of log records, with SSE client fan-out."""
45
+
46
+ def __init__(self, maxlen: int = 5000) -> None:
47
+ self._records: deque[LogRecord] = deque(maxlen=maxlen)
48
+ # One lock for records and subscribers alike: append() is atomic under
49
+ # the GIL on its own, but the subscriber set isn't, so a single lock
50
+ # keeps this simple rather than mixing locked and lock-free paths.
51
+ self._lock = threading.Lock()
52
+ self._subscribers: set[Queue[LogRecord]] = set()
53
+
54
+ def set_maxlen(self, maxlen: int) -> None:
55
+ # Rebuild in place rather than swap self._records for a fresh deque:
56
+ # deque(existing_deque, maxlen=N) already keeps only the newest N
57
+ # items, and callers who stashed a reference to this LogBuffer (e.g.
58
+ # future SSE subscribers) never observe a different object.
59
+ with self._lock:
60
+ self._records = deque(self._records, maxlen=maxlen)
61
+
62
+ def add(self, record: LogRecord) -> None:
63
+ with self._lock:
64
+ self._records.append(record)
65
+ subscribers = list(self._subscribers)
66
+ for subscriber in subscribers:
67
+ subscriber.put(record)
68
+
69
+ def snapshot(self) -> list[LogRecord]:
70
+ with self._lock:
71
+ return list(self._records)
72
+
73
+ def clear(self) -> None:
74
+ with self._lock:
75
+ self._records.clear()
76
+
77
+ def __len__(self) -> int:
78
+ with self._lock:
79
+ return len(self._records)
80
+
81
+ def subscribe(self) -> Queue[LogRecord]:
82
+ subscriber: Queue[LogRecord] = Queue()
83
+ with self._lock:
84
+ self._subscribers.add(subscriber)
85
+ return subscriber
86
+
87
+ def unsubscribe(self, subscriber: Queue[LogRecord]) -> None:
88
+ with self._lock:
89
+ self._subscribers.discard(subscriber)
lightlogger/handler.py ADDED
@@ -0,0 +1,40 @@
1
+ """LightloggerHandler: logging.Handler subclass for capture_logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from lightlogger.buffer import LogBuffer, LogRecord, _current_group_id
8
+
9
+ _LEVEL_NAMES = {
10
+ logging.DEBUG: "debug",
11
+ logging.INFO: "info",
12
+ logging.WARNING: "warn",
13
+ logging.ERROR: "error",
14
+ logging.CRITICAL: "error", # only 4 UI colors exist; CRITICAL folds into "error"
15
+ }
16
+
17
+
18
+ class LightloggerHandler(logging.Handler):
19
+ """Mirrors stdlib `logging` records into a `LogBuffer` (and its SSE fan-out)."""
20
+
21
+ def __init__(self, buffer: LogBuffer) -> None:
22
+ super().__init__()
23
+ self._buffer = buffer
24
+
25
+ def emit(self, record: logging.LogRecord) -> None:
26
+ try:
27
+ log_record: LogRecord = {
28
+ "time": record.created,
29
+ "level": _LEVEL_NAMES.get(record.levelno, "info"),
30
+ "message": record.getMessage(),
31
+ "data": None,
32
+ "file": record.pathname,
33
+ "line": record.lineno,
34
+ "logger_name": record.name,
35
+ "group_id": None,
36
+ "parent_group_id": _current_group_id.get(),
37
+ }
38
+ self._buffer.add(log_record)
39
+ except Exception:
40
+ self.handleError(record)
lightlogger/py.typed ADDED
File without changes
lightlogger/server.py ADDED
@@ -0,0 +1,147 @@
1
+ """ThreadingHTTPServer, daemon thread, and HTTP routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import json
7
+ import sys
8
+ import threading
9
+ from http import HTTPStatus
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from queue import Empty
12
+ from typing import Any
13
+
14
+ from lightlogger import sse
15
+ from lightlogger.buffer import LogBuffer
16
+
17
+ # How long an /api/stream client blocks on its queue before it gets a
18
+ # heartbeat comment instead. Also doubles as the heartbeat period.
19
+ HEARTBEAT_INTERVAL_SECONDS = 15.0
20
+
21
+
22
+ class LightloggerServer(ThreadingHTTPServer):
23
+ # SSE holds connections open indefinitely; without daemon threads per
24
+ # request, one slow client would starve every other route.
25
+ daemon_threads = True
26
+ allow_reuse_address = True
27
+
28
+ def handle_error(self, request: Any, client_address: Any) -> None:
29
+ # An /api/stream client that goes away (tab closed, reconnect churn)
30
+ # can reset the connection at any point, including while a *later*,
31
+ # unrelated request is being read on a freshly accepted socket. The
32
+ # default handle_error() dumps a full traceback to stderr for that,
33
+ # which looks like a server bug when it's just a disconnected client
34
+ # -- same spirit as log_message() already staying quiet above.
35
+ exc_type = sys.exc_info()[0]
36
+ if exc_type is not None and issubclass(exc_type, (BrokenPipeError, ConnectionResetError)):
37
+ return
38
+ super().handle_error(request, client_address)
39
+
40
+
41
+ def _make_handler(buffer: LogBuffer, heartbeat_interval: float) -> type[BaseHTTPRequestHandler]:
42
+ class Handler(BaseHTTPRequestHandler):
43
+ def log_message(self, format: str, *args: object) -> None:
44
+ pass # stay quiet on the user's stdout; they didn't ask for access logs
45
+
46
+ def do_GET(self) -> None:
47
+ if self.path == "/":
48
+ self._serve_index()
49
+ elif self.path == "/help":
50
+ self._serve_help()
51
+ elif self.path == "/api/logs":
52
+ self._serve_logs()
53
+ elif self.path == "/api/stream":
54
+ self._serve_stream()
55
+ else:
56
+ self.send_error(HTTPStatus.NOT_FOUND)
57
+
58
+ def do_POST(self) -> None:
59
+ if self.path == "/api/clear":
60
+ self._serve_clear()
61
+ else:
62
+ self.send_error(HTTPStatus.NOT_FOUND)
63
+
64
+ def _serve_clear(self) -> None:
65
+ buffer.clear()
66
+ self.send_response(HTTPStatus.OK)
67
+ self.send_header("Content-Length", "0")
68
+ self.end_headers()
69
+
70
+ def _serve_index(self) -> None:
71
+ html = (importlib.resources.files("lightlogger") / "static" / "index.html").read_bytes()
72
+ self.send_response(HTTPStatus.OK)
73
+ self.send_header("Content-Type", "text/html; charset=utf-8")
74
+ self.send_header("Content-Length", str(len(html)))
75
+ self.end_headers()
76
+ self.wfile.write(html)
77
+
78
+ def _serve_help(self) -> None:
79
+ html = (importlib.resources.files("lightlogger") / "static" / "help.html").read_bytes()
80
+ self.send_response(HTTPStatus.OK)
81
+ self.send_header("Content-Type", "text/html; charset=utf-8")
82
+ self.send_header("Content-Length", str(len(html)))
83
+ self.end_headers()
84
+ self.wfile.write(html)
85
+
86
+ def _serve_logs(self) -> None:
87
+ # default=str: log `data` can be any Python object the caller passed in.
88
+ body = json.dumps(buffer.snapshot(), default=str).encode("utf-8")
89
+ self.send_response(HTTPStatus.OK)
90
+ self.send_header("Content-Type", "application/json")
91
+ self.send_header("Content-Length", str(len(body)))
92
+ self.end_headers()
93
+ self.wfile.write(body)
94
+
95
+ def _serve_stream(self) -> None:
96
+ self.send_response(HTTPStatus.OK)
97
+ self.send_header("Content-Type", "text/event-stream")
98
+ self.send_header("Cache-Control", "no-cache")
99
+ self.send_header("Connection", "keep-alive")
100
+ self.end_headers()
101
+
102
+ # BaseHTTPRequestHandler gives no clean "client closed the tab"
103
+ # signal for a response this long-lived, so disconnect is detected
104
+ # the standard way: attempt the write, and treat any failure
105
+ # (BrokenPipeError, ConnectionResetError, or a generic OSError) as
106
+ # the client having gone away. finally: unsubscribe() always runs,
107
+ # so a leaked subscriber queue can't accumulate per disconnect.
108
+ subscriber = buffer.subscribe()
109
+ try:
110
+ self.wfile.write(sse.format_retry())
111
+ self.wfile.flush()
112
+ while True:
113
+ try:
114
+ record = subscriber.get(timeout=heartbeat_interval)
115
+ except Empty:
116
+ payload = sse.format_heartbeat()
117
+ else:
118
+ payload = sse.format_event(record)
119
+ self.wfile.write(payload)
120
+ self.wfile.flush()
121
+ except (BrokenPipeError, ConnectionResetError, OSError):
122
+ pass
123
+ finally:
124
+ buffer.unsubscribe(subscriber)
125
+
126
+ return Handler
127
+
128
+
129
+ def create_server(
130
+ buffer: LogBuffer,
131
+ host: str,
132
+ port: int,
133
+ heartbeat_interval: float = HEARTBEAT_INTERVAL_SECONDS,
134
+ ) -> LightloggerServer:
135
+ """Bind a server, auto-incrementing past `port` on OSError (port busy)."""
136
+ handler_cls = _make_handler(buffer, heartbeat_interval)
137
+ while True:
138
+ try:
139
+ return LightloggerServer((host, port), handler_cls)
140
+ except OSError:
141
+ port += 1
142
+
143
+
144
+ def serve_in_background(httpd: LightloggerServer) -> threading.Thread:
145
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
146
+ thread.start()
147
+ return thread
lightlogger/sse.py ADDED
@@ -0,0 +1,30 @@
1
+ """SSE framing helpers: exact wire format for text/event-stream responses.
2
+
3
+ Kept separate from server.py so the wire format (double-newline framing,
4
+ retry directive, heartbeat comment) is defined in exactly one place and can
5
+ be unit-tested without spinning up a real HTTP server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from lightlogger.buffer import LogRecord
13
+
14
+ RETRY_MS = 3000
15
+
16
+
17
+ def format_retry() -> bytes:
18
+ return f"retry: {RETRY_MS}\n\n".encode()
19
+
20
+
21
+ def format_event(record: LogRecord) -> bytes:
22
+ # default=str: log `data` can be any Python object the caller passed in.
23
+ payload = json.dumps(record, default=str)
24
+ return f"data: {payload}\n\n".encode()
25
+
26
+
27
+ def format_heartbeat() -> bytes:
28
+ # A comment line (leading colon) per the SSE spec: browsers/proxies ignore
29
+ # its content but the bytes on the wire keep the connection looking alive.
30
+ return b": ping\n\n"