mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:47:03 +01:00
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint were still grading `meta/` against 3.10 -- three tools judging an interpreter nothing runs any more, and ruff withholding the pyupgrade fixes that only become safe at the newer target. `pin_ci_python.py` rewrites these three knobs. What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC` is now correct (UP017), and ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`. That makes the source 3.14-only at the syntax level, which is what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately over holding ruff a release back (2026-08-29). Also drops a comment in `wsg_grabber/models.py` that the bump makes false: it explained why the file used ``str, Enum`` instead of ``StrEnum`` under mypy at 3.10, sitting directly above a ``class Verdict(StrEnum)`` that had already stopped obeying it. ruff, ruff-format, mypy --strict and pylint all clean at the new target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
193 lines
5.6 KiB
Python
193 lines
5.6 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 UTC, datetime
|
|
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=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=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)
|