mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
Worker did two separable jobs. Both become mixins, because the tests drive their methods as bound attributes (worker._download_one(), worker._fetch_thread) and free functions would have broken that: _scan.py ScanMixin -- threads.json, the archive, one changed thread _fetch.py DownloadMixin -- claim a file, fetch it, record the outcome _worker_base.py holds WorkerBase(ABC) with the state both halves touch and the three methods Worker supplies. Two earlier shapes were abandoned rather than suppressed: NotImplementedError stubs are uncovered lines under the 100% gate, and `if TYPE_CHECKING` stubs satisfy mypy but leave pylint reporting no-member. The ABC satisfies both with no disable comment anywhere. _downloaded needs a real class-level default: _fetch does `self._downloaded += 1` and pylint only accepts an attribute as assignable when there is something to assign to. Worker.__init__ still owns the instance value. downloader.py 420 -> 238, _scan.py 134, _fetch.py 98, _worker_base.py 52. 277 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
"""Walking the board: the thread list, the archive, and each changed thread.
|
|
|
|
Split out of :mod:`python_pkg.wsg_grabber.downloader` to keep it under the
|
|
250-line cap. This is a mixin rather than a set of free functions because the
|
|
tests drive these as bound methods (``worker._fetch_thread(...)``), and because
|
|
they share the worker's scan bookkeeping.
|
|
|
|
The bare annotations below declare what ``Worker.__init__`` actually sets, so
|
|
mypy can see the attributes without the mixin duplicating any of that state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.wsg_grabber import catalog, scanner, store, store_threads
|
|
from python_pkg.wsg_grabber._worker_base import WorkerBase
|
|
from python_pkg.wsg_grabber.constants import (
|
|
API_HOST,
|
|
ARCHIVE_URL,
|
|
BOARD,
|
|
THREADS_URL,
|
|
)
|
|
from python_pkg.wsg_grabber.models import Indexed, ThreadRef
|
|
from python_pkg.wsg_grabber.states import CLAIMABLE, FileState
|
|
|
|
if TYPE_CHECKING:
|
|
from python_pkg.wsg_grabber.models import JsonResponse
|
|
|
|
|
|
class ScanMixin(WorkerBase):
|
|
"""The board-walking half of :class:`~.downloader.Worker`."""
|
|
|
|
def _scan_step(self) -> None:
|
|
"""Read the thread list, or fetch the next thread that changed."""
|
|
if not self._catalog_fresh:
|
|
self._refresh_thread_list()
|
|
return
|
|
ref = self._pending_threads.pop(0)
|
|
self._fetch_thread(ref)
|
|
|
|
def _refresh_thread_list(self) -> None:
|
|
"""Fetch ``threads.json`` and work out which threads to visit."""
|
|
response = self._conditional_get(THREADS_URL)
|
|
live = catalog.parse_thread_list(response.payload)
|
|
if self._deps.include_archive:
|
|
live = live + self._archive_refs()
|
|
known = store_threads.known_threads(self._deps.conn)
|
|
self._pending_threads = scanner.stale_threads(known, live)
|
|
self._catalog_fresh = True
|
|
self._finished_announced = False
|
|
|
|
def _archive_refs(self) -> list[ThreadRef]:
|
|
"""Fetch the archive listing, tolerating boards without one.
|
|
|
|
Returns:
|
|
list[ThreadRef]: Archived threads, empty when unavailable.
|
|
"""
|
|
response = self._conditional_get(ARCHIVE_URL)
|
|
if response.not_found:
|
|
return []
|
|
return catalog.parse_archive(response.payload)
|
|
|
|
def _conditional_get(self, url: str) -> JsonResponse:
|
|
"""Fetch a board-level endpoint, sending back the stamp we hold.
|
|
|
|
Without this an idle reviewer re-fetches threads.json and archive.json
|
|
unconditionally every cycle. With it an unchanged board costs a 304.
|
|
|
|
Args:
|
|
url: Absolute API URL.
|
|
|
|
Returns:
|
|
JsonResponse: The response; on 304 the payload is None and the
|
|
caller keeps whatever it already knew.
|
|
"""
|
|
stamp = store_threads.cached_last_modified(self._deps.conn, url)
|
|
response = self._get_json(url, stamp)
|
|
if not response.not_modified:
|
|
store_threads.record_last_modified(
|
|
self._deps.conn,
|
|
url,
|
|
response.last_modified,
|
|
)
|
|
return response
|
|
|
|
def _fetch_thread(self, ref: ThreadRef) -> None:
|
|
"""Read one thread and record any files it introduces.
|
|
|
|
Args:
|
|
ref: Thread to fetch.
|
|
"""
|
|
url = f"{API_HOST}/{BOARD}/thread/{ref.thread_no}.json"
|
|
stamp = store_threads.http_last_modified(self._deps.conn, ref.thread_no)
|
|
response = self._get_json(url, stamp)
|
|
if response.not_found:
|
|
store_threads.mark_thread_gone(self._deps.conn, ref.thread_no)
|
|
return
|
|
if response.not_modified:
|
|
store_threads.record_thread(self._deps.conn, ref, stamp)
|
|
return
|
|
|
|
parsed = catalog.parse_thread(ref.thread_no, response.payload)
|
|
known = store.known_md5s(self._deps.conn)
|
|
added = store.record_files(
|
|
self._deps.conn,
|
|
catalog.new_files(parsed, known),
|
|
)
|
|
self._write_off_deleted(catalog.deleted_md5s(response.payload), known)
|
|
store_threads.record_thread(self._deps.conn, ref, response.last_modified)
|
|
if added:
|
|
self._publish(Indexed(known=len(known) + added))
|
|
|
|
def _write_off_deleted(self, gone: set[str], known: set[str]) -> None:
|
|
"""Mark not-yet-fetched files terminal when the board drops them.
|
|
|
|
Only files still waiting to download are affected. Once the bytes are
|
|
on disk the post being deleted upstream is irrelevant, so an already
|
|
downloaded or reviewed file keeps its state -- the same rule the
|
|
transition table encodes by rejecting ``READY + DELETED_UPSTREAM``.
|
|
|
|
Args:
|
|
gone: md5s the board reports as deleted.
|
|
known: md5s already in the index.
|
|
"""
|
|
for digest in gone & known:
|
|
if store.state_of(self._deps.conn, digest) not in CLAIMABLE:
|
|
continue
|
|
store.set_state(
|
|
self._deps.conn,
|
|
digest,
|
|
FileState.GONE,
|
|
error="deleted upstream",
|
|
)
|