mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:27:01 +01:00
New package. Walks every thread on 4chan's /wsg/, downloads each video it has
not seen, and reviews them one at a time: mpv embedded in a Tk window via --wid
with Keep/Pass/Undo directly beneath the picture.
Dedupe is structural rather than a convention. Every post carrying a file also
carries md5 -- base64 MD5 of the whole file -- so it is the sqlite primary key.
An already-seen video costs zero bytes to skip and a byte-identical repost in
another thread collapses onto the existing row. Verified against a live file:
the API's Nz9OEKdMuMZEdYE6eLTKmA== matched base64(md5(bytes)) exactly.
Undo is unbounded and survives quitting: the verdict trail lives in the index,
not in memory, so yesterday's decisions can still be taken back.
No downloaded video is ever deleted. A pass moves the file to trash/ and leaves
it there; files.apply_move refuses to overwrite an existing destination, so the
guarantee holds by construction rather than per caller.
Three bugs only running it could find, each caught from evidence rather than
guesswork:
- the video stayed black: a <Configure> handler set mpv's geometry, fighting
the automatic sizing --wid already does
- playback never advanced: refresh() ran every tick and re-sent loadfile ~7x
a second, restarting the video continuously
- it froze on one video around the 26th while filenames kept advancing: mpv
pushes an event stream down the same socket and nothing drained it, so the
buffer filled and mpv stopped reading commands. Isolated to command #35
without a drain thread; all 60 honoured with one.
A five-lens review then found nine more, all reproduced before fixing: a failed
mpv start stranded the non-daemon worker forever; undo could clobber a file in
incoming/; a NUL-bearing md5 poisoned the index permanently; an oversized int
killed the worker with no event; gzip bombs on both hosts were accepted; the
board endpoints were fetched unconditionally forever; and Session.missing could
corrupt the undo trail.
mpv runs as a subprocess over its JSON IPC socket rather than via python-mpv,
which resolves libmpv at import time and would fail on CI while passing here.
Third-party surface is requests alone, already declared.
277 package tests, 100% branch coverage on all 22 modules, no suppressions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
5.7 KiB
Python
193 lines
5.7 KiB
Python
r"""Structured, machine-readable event logging.
|
|
|
|
Every interesting thing this package does emits one JSON object per line, so a
|
|
session can be reconstructed afterwards without a debugger and without having
|
|
reproduced the problem live. That matters here more than usual: the parts most
|
|
likely to misbehave -- what mpv actually did with a command, whether a file was
|
|
still on disk when it was played -- sit exactly on the boundaries the test
|
|
suite has to mock, so tests cannot see them.
|
|
|
|
Reading a log back:
|
|
|
|
jq -c 'select(.event|startswith("player."))' <logfile>
|
|
jq -r 'select(.level=="error") | "\\(.ts) \\(.event) \\(.detail)"' <file>
|
|
jq -r 'select(.event=="review.show") | .file' <file> # what was shown, in order
|
|
|
|
Field names are stable. Every record carries ``ts``, ``seq``, ``level``,
|
|
``event`` and ``thread``; anything else is event-specific and always appears
|
|
under the same key.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
import itertools
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
from typing import TYPE_CHECKING, Final, TextIO
|
|
|
|
from python_pkg.wsg_grabber import paths
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
LEVELS: Final[tuple[str, ...]] = ("debug", "info", "warning", "error")
|
|
|
|
_RANK: Final[dict[str, int]] = {name: index for index, name in enumerate(LEVELS)}
|
|
|
|
|
|
@dataclass
|
|
class _Session:
|
|
"""Where records go and which ones are kept."""
|
|
|
|
sink: TextIO | None = None
|
|
path: Path | None = None
|
|
threshold: int = _RANK["info"]
|
|
echo: bool = False
|
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
counter: Iterator[int] = field(default_factory=lambda: itertools.count(1))
|
|
|
|
|
|
_SESSION = _Session()
|
|
|
|
|
|
def _free_path(directory: Path, stem: str) -> Path:
|
|
"""Return a log path that does not already exist.
|
|
|
|
The stem carries a whole-second timestamp and the pid, so restarting twice
|
|
inside one second would otherwise append a second session to the first
|
|
one's file and make the record numbering look corrupt.
|
|
|
|
Args:
|
|
directory: Where logs live.
|
|
stem: Preferred filename without its extension.
|
|
|
|
Returns:
|
|
Path: An unused path.
|
|
"""
|
|
candidate = directory / f"{stem}.jsonl"
|
|
suffix = 2
|
|
while candidate.exists():
|
|
candidate = directory / f"{stem}-{suffix}.jsonl"
|
|
suffix += 1
|
|
return candidate
|
|
|
|
|
|
def logs_dir() -> Path:
|
|
"""Return the directory holding session logs.
|
|
|
|
Returns:
|
|
Path: ``<data dir>/logs``.
|
|
"""
|
|
return paths.data_dir() / "logs"
|
|
|
|
|
|
def current_path() -> Path | None:
|
|
"""Return the log file this process is writing to.
|
|
|
|
Returns:
|
|
Path | None: The path, or None when logging has not been started.
|
|
"""
|
|
return _SESSION.path
|
|
|
|
|
|
def start(level: str = "info", *, echo: bool = False) -> Path:
|
|
"""Begin writing a session log and return its path.
|
|
|
|
Args:
|
|
level: Lowest level to record; one of :data:`LEVELS`.
|
|
echo: Also mirror records to stderr, for watching a run live.
|
|
|
|
Returns:
|
|
Path: The file now being written.
|
|
"""
|
|
stop()
|
|
directory = logs_dir()
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
stamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
path = _free_path(directory, f"session-{stamp}-{os.getpid()}")
|
|
with _SESSION.lock:
|
|
_SESSION.sink = path.open("a", encoding="utf-8")
|
|
_SESSION.path = path
|
|
_SESSION.threshold = _RANK.get(level, _RANK["info"])
|
|
_SESSION.echo = echo
|
|
# Restart numbering: seq orders records within one file, so carrying it
|
|
# over from a previous session would make the first line read as if
|
|
# records had been lost.
|
|
_SESSION.counter = itertools.count(1)
|
|
event("session.start", level_wanted=level, log=str(path), pid=os.getpid())
|
|
return path
|
|
|
|
|
|
def stop() -> None:
|
|
"""Close the session log, if one is open."""
|
|
with _SESSION.lock:
|
|
if _SESSION.sink is not None:
|
|
_SESSION.sink.flush()
|
|
_SESSION.sink.close()
|
|
_SESSION.sink = None
|
|
_SESSION.path = None
|
|
|
|
|
|
def event(name: str, level: str = "info", **fields: object) -> None:
|
|
"""Record one event.
|
|
|
|
Args:
|
|
name: Dotted event name, e.g. ``player.command``.
|
|
level: One of :data:`LEVELS`.
|
|
**fields: Event-specific values. Anything not JSON-encodable is
|
|
stringified rather than dropped, so a log line is never lost to a
|
|
serialisation error.
|
|
"""
|
|
if _RANK.get(level, _RANK["info"]) < _SESSION.threshold:
|
|
return
|
|
record = {
|
|
"ts": datetime.now(tz=timezone.utc).isoformat(),
|
|
"seq": next(_SESSION.counter),
|
|
"level": level,
|
|
"event": name,
|
|
"thread": threading.current_thread().name,
|
|
**fields,
|
|
}
|
|
line = json.dumps(record, default=str)
|
|
with _SESSION.lock:
|
|
if _SESSION.sink is not None:
|
|
_SESSION.sink.write(f"{line}\n")
|
|
_SESSION.sink.flush()
|
|
if _SESSION.echo:
|
|
sys.stderr.write(f"{line}\n")
|
|
|
|
|
|
def debug(name: str, **fields: object) -> None:
|
|
"""Record a debug-level event.
|
|
|
|
Args:
|
|
name: Dotted event name.
|
|
**fields: Event-specific values.
|
|
"""
|
|
event(name, level="debug", **fields)
|
|
|
|
|
|
def warning(name: str, **fields: object) -> None:
|
|
"""Record a warning-level event.
|
|
|
|
Args:
|
|
name: Dotted event name.
|
|
**fields: Event-specific values.
|
|
"""
|
|
event(name, level="warning", **fields)
|
|
|
|
|
|
def error(name: str, **fields: object) -> None:
|
|
"""Record an error-level event.
|
|
|
|
Args:
|
|
name: Dotted event name.
|
|
**fields: Event-specific values.
|
|
"""
|
|
event(name, level="error", **fields)
|