testsAndMisc/python_pkg/wsg_grabber/tests/test_paths.py
Krzysztof kuhy Rudnicki 89ed9bc95d
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass
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>
2026-07-27 14:55:30 +02:00

123 lines
3.5 KiB
Python

"""Tests for storage locations and for the isolation guarantee itself."""
from __future__ import annotations
import ast
import os
from pathlib import Path
from typing import TYPE_CHECKING
from python_pkg.wsg_grabber import paths
if TYPE_CHECKING:
import pytest
_HOME_CAPTURING_CALLS = frozenset({"home", "expanduser", "getenv", "expandvars"})
def test_data_dir_follows_xdg_data_home(tmp_path: Path) -> None:
assert paths.data_dir() == Path(os.environ["XDG_DATA_HOME"]) / "wsg_grabber"
assert str(paths.data_dir()).startswith(str(tmp_path))
def test_data_dir_falls_back_to_dot_local_share(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
expected = Path.home() / ".local" / "share" / "wsg_grabber"
assert paths.data_dir() == expected
assert str(paths.data_dir()).startswith(str(tmp_path))
def test_derived_paths_live_under_the_data_dir() -> None:
root = paths.data_dir()
for candidate in (
paths.db_path(),
paths.incoming_dir(),
paths.keep_dir(),
paths.trash_dir(),
paths.ipc_socket_path(),
):
assert candidate.parent == root
def test_socket_path_names_the_board() -> None:
assert paths.ipc_socket_path().name == "mpv-wsg.sock"
def test_ensure_dirs_creates_every_directory() -> None:
paths.ensure_dirs()
for directory in (
paths.data_dir(),
paths.incoming_dir(),
paths.keep_dir(),
paths.trash_dir(),
):
assert directory.is_dir()
def test_ensure_dirs_is_idempotent() -> None:
paths.ensure_dirs()
paths.ensure_dirs()
assert paths.incoming_dir().is_dir()
def _module_level_home_calls(source: str) -> list[str]:
"""Return names of home-resolving calls made outside any function body.
Args:
source: Python source text of one module.
Returns:
list[str]: Offending attribute/function names, empty when the module is
clean.
"""
tree = ast.parse(source)
offenders: list[str] = []
for node in tree.body:
for inner in ast.walk(node):
if isinstance(inner, (ast.FunctionDef, ast.AsyncFunctionDef)):
break
else:
offenders.extend(_home_calls_in(node))
return offenders
def _home_calls_in(node: ast.AST) -> list[str]:
"""Collect home-resolving call names anywhere beneath *node*.
Args:
node: AST node to scan.
Returns:
list[str]: Names such as ``home`` or ``expanduser``.
"""
found: list[str] = []
for inner in ast.walk(node):
if not isinstance(inner, ast.Call):
continue
func = inner.func
name = func.attr if isinstance(func, ast.Attribute) else None
if name is None and isinstance(func, ast.Name):
name = func.id
if name in _HOME_CAPTURING_CALLS:
found.append(name)
return found
def test_no_module_captures_a_home_directory_at_import_time() -> None:
"""Guard the whole package, not just paths.py.
A constant like ``STATE = Path.home() / "x"`` is evaluated on import and
can never be redirected afterwards, which is exactly how a previous package
in this repo leaked test writes into real user data.
"""
package_root = Path(paths.__file__).parent
offenders: dict[str, list[str]] = {}
for source_file in sorted(package_root.glob("*.py")):
calls = _module_level_home_calls(source_file.read_text(encoding="utf-8"))
if calls:
offenders[source_file.name] = calls
assert offenders == {}