Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/wordstat/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,40 @@ def collect(
except WordstatError as error:
raise click.ClickException(str(error)) from error

partial_count = 0
for result in batch.results:
click.echo(result.manifest_path)
# issue #27: a result with missing_views is one where at least one
# view failed (but at least one other succeeded — a phrase where
# nothing at all was collected never reaches batch.results, see
# _collect_one's own guard) — surface that per view instead of
# letting "Собрано N из M" imply every phrase in it is complete.
# Deliberately NOT keyed off manifest.status: status is
# "incomplete" whenever empty_views is non-empty too, and
# top_popular/top_related are legitimately empty on every live
# Wordstat run (issue #22/#25) — keying success on status would
# make ordinary complete runs exit 1 again.
#
# Union with view_errors, not manifest.missing_views alone (cycle-review
# follow-up to #27): missing_views is a computed field over
# manifest.exports only, which under --resume-dir can still list a view
# as "exported" from a *prior* run even though that view's parquet is
# gone from disk and this run's re-collection attempt failed for it
# (views_to_collect re-attempts a view whose export entry survives but
# whose file doesn't — see storage.py). In that case view_errors has an
# entry for the view but missing_views is empty, so keying only off
# missing_views silently reported a run with a missing parquet as a
# full, error-free success at exit 0 — the exact "врёт о фактическом
# результате" failure mode issue #27 was about, just from the opposite
# direction (a stale manifest entry instead of a fresh gap).
reported_views = set(result.manifest.missing_views) | set(result.view_errors)
if reported_views:
partial_count += 1
for view in sorted(reported_views, key=lambda v: v.value):
reason = result.view_errors.get(view, "не собран")
click.echo(f" {result.manifest.phrase} [{view.value}]: {reason}", err=True)
for warning in result.escaped_download_warnings:
click.echo(f" {result.manifest.phrase}: {warning}", err=True)
for failure in batch.failures:
click.echo(f"{failure.phrase}: {failure.error}", err=True)

Expand All @@ -179,6 +211,7 @@ def collect(
# attempted, so "Собрано N из M" alone would misleadingly read as "all the
# rest failed".
suffix = f" (батч прерван, {skipped} фраз(ы) не пробовались)" if skipped else ""
click.echo(f"Собрано {len(batch.results)} из {batch.total}{suffix}", err=True)
partial_suffix = f", из них частично {partial_count}" if partial_count else ""
click.echo(f"Собрано {len(batch.results)} из {batch.total}{partial_suffix}{suffix}", err=True)
if batch.failures:
ctx.exit(1)
357 changes: 286 additions & 71 deletions src/wordstat/collector.py

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions src/wordstat/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ class DownloadTimeoutError(WordstatError):
"""The UI accepted an export request but did not produce a CSV file."""


class DownloadEscapedError(WordstatError):
"""Chrome reported a download outside the run's own downloads directory.

session.downloaded_files (browser-use) accumulates every path Chrome has
ever reported as downloaded across the whole CDP session, regardless of
where it actually landed — see issue #27, where the fourth view of a
phrase (regions) was reported at an absolute path under the user's real
~/Downloads instead of the collector's own temporary downloads_path. That
path must never be moved or deleted (it may be a file the user cares
about, and may not even belong to this tool's run at all): this error is
raised instead, so the file is left untouched and the failure is loud
rather than a silent Errno 1/2 from finalize_raw trying to relocate it.
"""


class CsvFormatError(WordstatError):
"""A downloaded file cannot be decoded as a headed CSV report."""

Expand Down
28 changes: 28 additions & 0 deletions src/wordstat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,39 @@ class CollectionResult(BaseModel):

The parsed rows are deliberately not carried here: they are already on disk
as Parquet, and the manifest describes every export.

``view_errors`` (issue #27) records why a view present in
``manifest.missing_views`` did not make it into ``manifest.exports`` —
the manifest itself only carries a view's absence, not the reason
(``DownloadTimeoutError``, ``InterfaceChangedError``, a parse failure,
...). A phrase where at least one view failed but at least one other
succeeded still comes back as a ``CollectionResult`` (not a batch
failure — see ``collector._collect_one``), so the operator needs
somewhere to read *why* a view is missing without re-running with more
logging. Empty when every requested view was collected successfully.
Keyed by :class:`WordstatView`, not the raw exception object: the error
is only ever read back as text (CLI output), and keeping the exception
itself here would hold its traceback/frames alive for as long as this
result is (same reasoning as ``PhraseFailure.error`` being stripped of
its traceback in ``collector._without_traceback``).

``escaped_download_warnings`` (issue #27 follow-up) records a case where
Chrome reported a download outside the run's ``downloads_path`` in the
same polling tick as the legitimate CSV for the current view — the view
itself still succeeded (its own CSV is fine), so this must not fail the
view or the phrase, but the operator still needs to know Chrome dropped
a stray file somewhere it wasn't supposed to (the file itself is never
touched — see ``_escaped_download_paths``/``DownloadEscapedError``).
Plain strings, not keyed by view: a single poll tick can only ever
belong to the view currently being downloaded, so the message names the
view and path together. Empty on every normal run.
"""

run_directory: Path
manifest_path: Path
manifest: CollectionManifest
view_errors: dict[WordstatView, str] = Field(default_factory=dict)
escaped_download_warnings: list[str] = Field(default_factory=list)


class PhraseFailure(BaseModel):
Expand Down
47 changes: 44 additions & 3 deletions src/wordstat/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import os
import re
import shutil
import tempfile
from datetime import UTC, datetime
from pathlib import Path

from wordstat.errors import ResumeMismatchError
from wordstat.errors import DownloadEscapedError, ResumeMismatchError
from wordstat.models import CollectionManifest, ExportSummary, WordstatView


Expand All @@ -32,19 +33,59 @@ def slugify(value: str) -> str:
return compact[:64] or "query"


def finalize_raw(source: Path, run_directory: Path, view: WordstatView, keep_raw: bool) -> Path | None:
def finalize_raw(
source: Path, run_directory: Path, view: WordstatView, keep_raw: bool, output_root: Path | None = None
) -> Path | None:
"""Dispose of a download once it has been converted to Parquet.

By default the raw CSV is removed, leaving only the converted datasets in
the run directory. With ``keep_raw`` it is renamed to the view's canonical
name so it sits next to its ``<view>.parquet`` counterpart.

Belt-and-suspenders against issue #27 (a downloaded file that ends up
outside the tool's own downloads_path, e.g. under the user's real
~/Downloads on macOS): a source that is not inside ``run_directory``
itself, nor inside ``output_root`` (the batch's shared temporary
downloads directory lives under it — see ``collector.collect_many``), is
refused outright with ``DownloadEscapedError`` instead of being moved or
deleted. ``output_root`` is a caller-supplied parameter rather than
inferred from ``run_directory``'s parents: under ``--resume-dir``,
``run_directory`` can be an arbitrary user-supplied path that is not
necessarily two levels under ``--output-dir`` at all, and inferring it
would either reject a legitimate download or (worse) widen the allowed
zone unpredictably. Callers that have no ``output_root`` to pass (e.g.
existing tests that only care about the rename/delete behavior) may omit
it, in which case only ``run_directory`` is treated as safe. This is a
second line of defense — collector.py's ``_download_current_view``
already refuses to hand such a path to this function at all — kept here
in case any future caller reaches this function directly with an
unchecked path. Checked before the ``missing_ok`` unlink path too: a
caller passing ``keep_raw=False`` for a stray path must not silently
delete a file that does not belong to this run.
"""

resolved_source = source.resolve()
allowed_roots = [run_directory.resolve()]
if output_root is not None:
allowed_roots.append(output_root.resolve())
if source.exists() and not any(resolved_source.is_relative_to(root) for root in allowed_roots):
raise DownloadEscapedError(
f"Refusing to move or delete {source} — it is outside the run "
f"directory ({run_directory}) and its output root ({output_root}); "
"it is not safe to assume this tool owns that file."
)

if not keep_raw:
source.unlink(missing_ok=True)
return None
destination = run_directory / f"{view.value}.csv"
return source.replace(destination)
# shutil.move tries an atomic os.rename first and only falls back to a
# copy+unlink when source/destination sit on different filesystems (e.g.
# --output-dir on a different mount than the batch's shared downloads
# directory) — Path.replace (bare os.rename) raises OSError in that case
# instead of relocating the file.
shutil.move(str(source), str(destination))
return destination


def write_manifest(path: Path, manifest: CollectionManifest) -> None:
Expand Down
171 changes: 170 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
from wordstat import cli
from wordstat.cli import main, resolve_phrases
from wordstat.errors import PhraseEntryError
from wordstat.models import BatchCollectionResult, CollectionManifest, CollectionResult, PhraseFailure
from wordstat.models import (
BatchCollectionResult,
CollectionManifest,
CollectionResult,
ExportSummary,
PhraseFailure,
WordstatView,
)


def test_empty_phrase_is_reported_without_a_traceback():
Expand Down Expand Up @@ -116,6 +123,168 @@ async def fake_collect_many(self, phrases, region="Россия", resume_directo
assert "Собрано 1 из 1" in result.output


def _fake_manifest_with_missing_view(phrase: str) -> CollectionManifest:
return CollectionManifest(
phrase=phrase,
region="Россия",
created_at=datetime(2026, 8, 20, 12, 0, tzinfo=UTC),
source_url="https://wordstat.yandex.ru/?words=" + phrase,
exports=[
ExportSummary(
view=WordstatView.TOP_POPULAR,
file="top_popular.parquet",
raw_file=None,
row_count=0,
dtypes={"Запрос": "string"},
),
ExportSummary(
view=WordstatView.TOP_RELATED,
file="top_related.parquet",
raw_file=None,
row_count=0,
dtypes={"Запрос": "string"},
),
ExportSummary(
view=WordstatView.DYNAMICS,
file="dynamics.parquet",
raw_file=None,
row_count=12,
dtypes={"Дата": "string"},
),
# regions deliberately absent: missing_views == [REGIONS]
],
)


def test_batch_partial_phrase_exits_zero_and_reports_the_missing_view(monkeypatch, tmp_path: Path):
"""Issue #27: a phrase that collected 3 of 4 views must not print
"Собрано 0 из 1" or exit non-zero — it belongs in batch.results (not
batch.failures), same as a fully collected phrase, but the CLI must
still surface which view is missing and why. Deliberately does NOT
assert on manifest.status, which is "incomplete" here for an unrelated
reason (empty_views from the always-empty top_popular/top_related, see
CLAUDE.md/issue #22) — exit code must not be keyed off that field."""

async def fake_collect_many(self, phrases, region="Россия", resume_directory=None):
run_directory = tmp_path / "чай"
manifest_path = run_directory / "manifest.json"
return BatchCollectionResult(
total=1,
results=[
CollectionResult(
run_directory=run_directory,
manifest_path=manifest_path,
manifest=_fake_manifest_with_missing_view("чай"),
view_errors={WordstatView.REGIONS: "DownloadTimeoutError: simulated"},
)
],
failures=[],
)

monkeypatch.setattr(cli.WordstatCollector, "collect_many", fake_collect_many)

result = CliRunner().invoke(main, ["collect", "чай"])

assert result.exit_code == 0
assert "Собрано 1 из 1, из них частично 1" in result.output
assert "чай [regions]: DownloadTimeoutError: simulated" in result.output


def _fake_manifest_with_stale_export_for_regions(phrase: str) -> CollectionManifest:
"""All four views present in exports, including a *stale* REGIONS entry
(as if a prior run collected it, but the on-disk parquet was later
deleted) — mirrors --resume-dir's views_to_collect re-attempting a view
whose export entry survives but whose file doesn't (see storage.py). With
all four exports present, `missing_views` (computed only from `exports`)
is empty even though this call's re-collection of REGIONS failed."""
base = _fake_manifest_with_missing_view(phrase)
return base.model_copy(
update={
"exports": [
*base.exports,
ExportSummary(
view=WordstatView.REGIONS,
file="regions.parquet",
raw_file=None,
row_count=934,
dtypes={"Регион": "string"},
),
]
}
)


def test_batch_resume_reattempt_failure_is_reported_even_when_missing_views_is_empty(
monkeypatch, tmp_path: Path
):
"""Cycle-review follow-up to issue #27: missing_views is a computed field
over manifest.exports only. Under --resume-dir, a view can have a stale
export entry (its parquet was manually deleted, but the manifest still
lists it) — views_to_collect re-attempts such a view, and if that
re-attempt fails, view_errors gets an entry for it but missing_views
stays empty (the stale export entry is still in manifest.exports). Keying
the CLI's partial-report gate on `missing_views` alone would silently
report this run as a full success at exit 0 while the view's parquet is
still absent — the exact "врёт о фактическом результате" failure mode
issue #27 was about. The gate must be the union of missing_views and
view_errors."""

async def fake_collect_many(self, phrases, region="Россия", resume_directory=None):
run_directory = tmp_path / "чай"
manifest_path = run_directory / "manifest.json"
return BatchCollectionResult(
total=1,
results=[
CollectionResult(
run_directory=run_directory,
manifest_path=manifest_path,
manifest=_fake_manifest_with_stale_export_for_regions("чай"),
view_errors={WordstatView.REGIONS: "DownloadTimeoutError: simulated resume re-attempt failure"},
)
],
failures=[],
)

monkeypatch.setattr(cli.WordstatCollector, "collect_many", fake_collect_many)

result = CliRunner().invoke(main, ["collect", "чай"])

assert result.exit_code == 0
assert "Собрано 1 из 1, из них частично 1" in result.output
assert "чай [regions]: DownloadTimeoutError: simulated resume re-attempt failure" in result.output


def test_batch_reports_escaped_download_warnings(monkeypatch, tmp_path: Path):
"""A view that succeeded despite a same-tick escaped download (cycle-
review follow-up to issue #27) must still surface the warning to the
operator, even though the phrase is otherwise fully collected."""

async def fake_collect_many(self, phrases, region="Россия", resume_directory=None):
run_directory = tmp_path / "чай"
manifest_path = run_directory / "manifest.json"
return BatchCollectionResult(
total=1,
results=[
CollectionResult(
run_directory=run_directory,
manifest_path=manifest_path,
manifest=_fake_manifest_with_stale_export_for_regions("чай"),
escaped_download_warnings=[
"[regions] Chrome reported a download outside the run's downloads directory: stray.csv"
],
)
],
failures=[],
)

monkeypatch.setattr(cli.WordstatCollector, "collect_many", fake_collect_many)

result = CliRunner().invoke(main, ["collect", "чай"])

assert result.exit_code == 0
assert "чай: [regions] Chrome reported a download outside the run's downloads directory" in result.output


def test_batch_aborted_early_reports_untried_phrases_distinctly(monkeypatch):
async def fake_collect_many(self, phrases, region="Россия", resume_directory=None):
# Only the first of 3 phrases was attempted (and failed) before the
Expand Down
Loading
Loading