Skip to content

Commit fed53f0

Browse files
authored
Merge pull request #12 from nikhilvdev/feat/phase-8-cli
Add logquill CLI tail command
2 parents edb5a81 + eee8711 commit fed53f0

5 files changed

Lines changed: 458 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project are documented in this file.
44

55
## Unreleased
66

7+
- Phase 8, CLI, complete:
8+
- `logquill tail <file> [--level=] [--json] [-f/--follow] [-n/--lines]` — a
9+
`logquill` console-script for tailing a JSONL log file in local dev.
10+
Human-readable output by default, colorized by level to match
11+
`ConsoleTransport`; `--json` prints each matching record as a raw JSON
12+
line instead. `--level` filters to that level and above; `-n` limits to
13+
the last N matching records; `-f`/`--follow` keeps polling the file for
14+
newly appended records, for a `tail -f`-style live view. A line that
15+
isn't valid JSON, or isn't a JSON object, is skipped with a warning on
16+
stderr instead of aborting the whole tail.
717
- Phase 7, advanced context & stdlib bridge, complete:
818
- `bind_context(**values)` — a `contextvars`-based context manager that
919
merges `values` into every `Logger` call underneath it, through any

‎README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ for what's landed so far.
3131
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
3232
- **Typed throughout** — `mypy --strict` clean on the public API
3333
- **Context propagation, exception capture & the stdlib bridge** — `bind_context()` (`contextvars`-based, no manual passing), `exc_info=` on any `Logger` method (formatted traceback into `meta["stack"]`), `LogQuillHandler` (bridges stdlib `logging` into a `Logger`), and `RateLimitPlugin` — see [Context propagation, exception capture & the stdlib bridge](#context-propagation-exception-capture--the-stdlib-bridge)
34+
- **CLI** — `logquill tail app.log --level=warn --json -f` for filtering/following a JSONL log file in local dev, no extra install — see [CLI](#cli)
3435

3536
## Install
3637

@@ -854,6 +855,32 @@ for _ in range(100):
854855
logger.error("connection refused") # only the first 5 per minute ship
855856
```
856857

858+
## CLI
859+
860+
Installing `logquill` also installs a `logquill` command for local
861+
development — no extra dependencies, since it only reads the JSONL files any
862+
`FileTransport`/`ConsoleTransport` already writes:
863+
864+
```bash
865+
# print every record in the file, human-readable
866+
logquill tail app.log
867+
868+
# only WARN and above, as raw JSON lines
869+
logquill tail app.log --level=warn --json
870+
871+
# only the last 20 matching records
872+
logquill tail app.log -n 20
873+
874+
# keep watching the file and print new records as they're appended, like `tail -f`
875+
logquill tail app.log -f
876+
```
877+
878+
Human-readable output is colorized by level (matching `ConsoleTransport`'s
879+
colors) when writing to a terminal; pass `--no-color` to disable that, or
880+
`--json` to print each matching record as a single JSON line instead. A line
881+
that isn't valid JSON, or isn't a JSON object, is skipped with a warning on
882+
stderr rather than aborting the whole tail.
883+
857884
## Development
858885

859886
```bash

‎logquill/cli.py‎

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""The `logquill` command-line entry point (`pip install logquill` → `logquill tail ...`)."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import contextlib
7+
import json
8+
import sys
9+
import time
10+
from pathlib import Path
11+
from typing import IO, Any, Sequence
12+
13+
from logquill.levels import Level, parse_level
14+
15+
_COLORS = {
16+
Level.TRACE: "\x1b[90m",
17+
Level.DEBUG: "\x1b[36m",
18+
Level.INFO: "\x1b[32m",
19+
Level.WARN: "\x1b[33m",
20+
Level.ERROR: "\x1b[31m",
21+
Level.FATAL: "\x1b[35m",
22+
}
23+
_RESET = "\x1b[0m"
24+
25+
26+
def build_parser() -> argparse.ArgumentParser:
27+
parser = argparse.ArgumentParser(
28+
prog="logquill", description="LogQuill command-line tools for local development."
29+
)
30+
subparsers = parser.add_subparsers(dest="command", required=True)
31+
32+
tail_parser = subparsers.add_parser(
33+
"tail", help="Print (and optionally follow) a LogQuill JSONL log file."
34+
)
35+
tail_parser.add_argument("file", help="Path to a LogQuill JSONL log file.")
36+
tail_parser.add_argument(
37+
"--level",
38+
default=None,
39+
help="Only show records at or above this level (e.g. --level=error).",
40+
)
41+
tail_parser.add_argument(
42+
"--json",
43+
action="store_true",
44+
help="Print raw JSON lines instead of human-readable text.",
45+
)
46+
tail_parser.add_argument(
47+
"-f",
48+
"--follow",
49+
action="store_true",
50+
help="Keep watching the file and print new records as they're appended.",
51+
)
52+
tail_parser.add_argument(
53+
"-n",
54+
"--lines",
55+
type=int,
56+
default=None,
57+
metavar="N",
58+
help="Only show the last N matching records instead of the whole file.",
59+
)
60+
tail_parser.add_argument(
61+
"--no-color",
62+
action="store_true",
63+
help="Disable ANSI colorization, even when writing to a terminal.",
64+
)
65+
return parser
66+
67+
68+
def _passes_filter(record: dict[str, Any], min_level: Level | None) -> bool:
69+
if min_level is None:
70+
return True
71+
try:
72+
return parse_level(record.get("level")) >= min_level # type: ignore[arg-type]
73+
except (TypeError, ValueError):
74+
return False
75+
76+
77+
def _parse_line(line: str, *, warn_stream: IO[str]) -> dict[str, Any] | None:
78+
line = line.strip()
79+
if not line:
80+
return None
81+
try:
82+
parsed = json.loads(line)
83+
except json.JSONDecodeError:
84+
warn_stream.write(f"logquill tail: skipping malformed JSON line: {line[:200]!r}\n")
85+
return None
86+
if not isinstance(parsed, dict):
87+
warn_stream.write(f"logquill tail: skipping non-object JSON line: {line[:200]!r}\n")
88+
return None
89+
return parsed
90+
91+
92+
def _format_human(record: dict[str, Any], *, colorize: bool) -> str:
93+
level_name = str(record.get("level", "?"))
94+
timestamp = record.get("timestamp", "?")
95+
logger_name = record.get("logger", "?")
96+
message = record.get("message", "")
97+
meta = record.get("meta") or {}
98+
99+
line = f"{timestamp} {level_name:<5} {logger_name}: {message}"
100+
if meta:
101+
line += f" {json.dumps(meta, separators=(',', ':'), default=str)}"
102+
103+
if colorize:
104+
try:
105+
color = _COLORS.get(parse_level(level_name))
106+
except (TypeError, ValueError):
107+
color = None
108+
if color:
109+
line = f"{color}{line}{_RESET}"
110+
return line
111+
112+
113+
def _emit(record: dict[str, Any], *, as_json: bool, colorize: bool, out: IO[str]) -> None:
114+
if as_json:
115+
out.write(json.dumps(record, separators=(",", ":"), default=str) + "\n")
116+
else:
117+
out.write(_format_human(record, colorize=colorize) + "\n")
118+
out.flush()
119+
120+
121+
def _read_existing(
122+
path: Path,
123+
*,
124+
min_level: Level | None,
125+
lines: int | None,
126+
as_json: bool,
127+
colorize: bool,
128+
out: IO[str],
129+
warn_stream: IO[str],
130+
) -> int:
131+
"""Prints every matching record currently in the file; returns the byte offset at EOF."""
132+
matched: list[dict[str, Any]] = []
133+
with path.open("r", encoding="utf-8") as f:
134+
for raw_line in f:
135+
record = _parse_line(raw_line, warn_stream=warn_stream)
136+
if record is not None and _passes_filter(record, min_level):
137+
matched.append(record)
138+
offset = f.tell()
139+
140+
if lines is not None:
141+
matched = matched[-lines:]
142+
for record in matched:
143+
_emit(record, as_json=as_json, colorize=colorize, out=out)
144+
return offset
145+
146+
147+
def _follow(
148+
path: Path,
149+
offset: int,
150+
*,
151+
min_level: Level | None,
152+
as_json: bool,
153+
colorize: bool,
154+
out: IO[str],
155+
warn_stream: IO[str],
156+
poll_interval: float = 0.5,
157+
max_iterations: int | None = None,
158+
) -> None:
159+
"""Polls `path` for lines appended after `offset`, forever unless `max_iterations` is set.
160+
161+
A polling loop rather than an inotify/kqueue watch: it keeps this module
162+
dependency-free and behaves the same across platforms, at the cost of up to
163+
`poll_interval` seconds of latency on a new line — an acceptable trade for a
164+
local dev tool.
165+
"""
166+
iterations = 0
167+
while max_iterations is None or iterations < max_iterations:
168+
iterations += 1
169+
try:
170+
with path.open("r", encoding="utf-8") as f:
171+
f.seek(offset)
172+
new_lines = f.readlines()
173+
offset = f.tell()
174+
except FileNotFoundError:
175+
time.sleep(poll_interval)
176+
continue
177+
178+
for raw_line in new_lines:
179+
record = _parse_line(raw_line, warn_stream=warn_stream)
180+
if record is not None and _passes_filter(record, min_level):
181+
_emit(record, as_json=as_json, colorize=colorize, out=out)
182+
183+
time.sleep(poll_interval)
184+
185+
186+
def _run_tail(
187+
args: argparse.Namespace,
188+
*,
189+
min_level: Level | None,
190+
out: IO[str],
191+
warn_stream: IO[str],
192+
) -> int:
193+
path = Path(args.file)
194+
if not path.exists():
195+
warn_stream.write(f"logquill tail: no such file: {args.file}\n")
196+
return 1
197+
198+
colorize = not args.no_color and not args.json and getattr(out, "isatty", lambda: False)()
199+
offset = _read_existing(
200+
path,
201+
min_level=min_level,
202+
lines=args.lines,
203+
as_json=args.json,
204+
colorize=colorize,
205+
out=out,
206+
warn_stream=warn_stream,
207+
)
208+
209+
if args.follow:
210+
with contextlib.suppress(KeyboardInterrupt):
211+
_follow(
212+
path,
213+
offset,
214+
min_level=min_level,
215+
as_json=args.json,
216+
colorize=colorize,
217+
out=out,
218+
warn_stream=warn_stream,
219+
)
220+
return 0
221+
222+
223+
def main(argv: Sequence[str] | None = None) -> int:
224+
parser = build_parser()
225+
args = parser.parse_args(argv)
226+
227+
if args.command != "tail":
228+
parser.error(f"Unknown command: {args.command}")
229+
230+
try:
231+
min_level = parse_level(args.level) if args.level is not None else None
232+
except ValueError as exc:
233+
parser.error(str(exc))
234+
return 2 # pragma: no cover - argparse.error() already exits
235+
236+
return _run_tail(args, min_level=min_level, out=sys.stdout, warn_stream=sys.stderr)
237+
238+
239+
if __name__ == "__main__": # pragma: no cover
240+
sys.exit(main())

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ hooks = [
7272
"pre-commit>=3.7",
7373
]
7474

75+
[project.scripts]
76+
logquill = "logquill.cli:main"
77+
7578
[project.urls]
7679
Homepage = "https://github.com/nikhilvdev/logquill-python"
7780
Repository = "https://github.com/nikhilvdev/logquill-python"

0 commit comments

Comments
 (0)