From 5d451ef480e93cf3d96d9736649899a522ffe90e Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 21 Aug 2026 10:58:16 +0700 Subject: [PATCH 1/2] fix(csv): preserve semicolon schema for header-only exports --- src/wordstat/csv_io.py | 21 ++++++++++++++-- tests/test_csv_io.py | 57 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/wordstat/csv_io.py b/src/wordstat/csv_io.py index 2022f27..9b4fb2b 100644 --- a/src/wordstat/csv_io.py +++ b/src/wordstat/csv_io.py @@ -1,4 +1,8 @@ -"""CSV decoding for Wordstat downloads.""" +"""CSV decoding for Wordstat downloads. + +Wordstat exports are commonly UTF-8 with a BOM and may use a lone CR as the +line terminator. ``cp1251`` remains a supported fallback for older exports. +""" import csv from collections.abc import Iterable @@ -10,6 +14,12 @@ _ENCODINGS = ("utf-8-sig", "utf-8", "cp1251") +class _SemicolonDialect(csv.excel): + """Fallback dialect for Wordstat's known separator.""" + + delimiter = ";" + + def parse_wordstat_csv(path: Path, view: WordstatView) -> CsvDataset: """Decode a Wordstat CSV while preserving its localized column names. @@ -40,11 +50,16 @@ def _read_text(path: Path) -> str: def _detect_dialect(text: str) -> csv.Dialect: + # Sniffer cannot establish a delimiter from a header-only export. Do not + # let its Excel fallback turn commas inside a localized header into + # columns; Wordstat's separator is semicolon. + if len(text.splitlines()) < 2: + return _SemicolonDialect() sample = text[:4096] try: return csv.Sniffer().sniff(sample, delimiters=";,\t") except csv.Error: - return csv.excel + return _SemicolonDialect() def _validate_headers(fieldnames: Iterable[str | None] | None, path: Path) -> list[str]: @@ -53,6 +68,8 @@ def _validate_headers(fieldnames: Iterable[str | None] | None, path: Path) -> li headers = [header.strip() if header else "" for header in fieldnames] if not all(headers): raise CsvFormatError(f"CSV {path.name} contains an empty header") + if any(";" in header for header in headers): + raise CsvFormatError(f"CSV {path.name} contains an unparsed delimiter in a header") if len(set(headers)) != len(headers): raise CsvFormatError(f"CSV {path.name} contains duplicate headers") return headers diff --git a/tests/test_csv_io.py b/tests/test_csv_io.py index 8f16805..d66f7d1 100644 --- a/tests/test_csv_io.py +++ b/tests/test_csv_io.py @@ -2,7 +2,7 @@ import pytest -from wordstat.csv_io import parse_wordstat_csv +from wordstat.csv_io import _validate_headers, parse_wordstat_csv from wordstat.errors import CsvFormatError from wordstat.models import WordstatView @@ -27,6 +27,61 @@ def test_parse_wordstat_csv_reads_a_tab_delimited_export(tmp_path: Path) -> None assert dataset.rows == [{"Запрос": "чай", "Число запросов": "5228679"}] +def test_parse_wordstat_csv_reads_header_only_semicolon_export(tmp_path: Path) -> None: + source = tmp_path / "report.csv" + source.write_text( + "Запросы со словами;Число запросов;" + "Топ частотных запросов «новогодние подарки», 20.07.2026 — 20.08.2026, Россия, все устройства", + encoding="utf-8", + ) + + dataset = parse_wordstat_csv(source, WordstatView.TOP_POPULAR) + + assert dataset.headers == [ + "Запросы со словами", + "Число запросов", + "Топ частотных запросов «новогодние подарки», 20.07.2026 — 20.08.2026, Россия, все устройства", + ] + assert all(";" not in header for header in dataset.headers) + assert dataset.rows == [] + + +def test_parse_wordstat_csv_reads_bom_and_cr_header_only_export(tmp_path: Path) -> None: + source = tmp_path / "report.csv" + source.write_bytes( + "Запросы со словами;Число запросов;Топ частотных запросов «новогодние подарки», Россия".encode( + "utf-8-sig" + ) + + b"\r" + ) + + dataset = parse_wordstat_csv(source, WordstatView.TOP_RELATED) + + assert dataset.headers == [ + "Запросы со словами", + "Число запросов", + "Топ частотных запросов «новогодние подарки», Россия", + ] + assert dataset.rows == [] + + +def test_validate_headers_rejects_unparsed_semicolon(tmp_path: Path) -> None: + source = tmp_path / "report.csv" + + with pytest.raises(CsvFormatError, match="unparsed delimiter"): + _validate_headers(["first;broken header"], source) + + +def test_parse_wordstat_csv_keeps_normal_multiline_export_working(tmp_path: Path) -> None: + source = tmp_path / "report.csv" + source.write_text("Запрос;Число запросов\nчай;5\nкофе;3\n", encoding="utf-8") + + dataset = parse_wordstat_csv(source, WordstatView.TOP_POPULAR) + + assert dataset.headers == ["Запрос", "Число запросов"] + assert dataset.rows == [{"Запрос": "чай", "Число запросов": "5"}, {"Запрос": "кофе", "Число запросов": "3"}] + + def test_parse_wordstat_csv_rejects_duplicate_headers(tmp_path: Path) -> None: source = tmp_path / "report.csv" source.write_text("query,query\none,two\n", encoding="utf-8") From 7956cf5e77a9f86c92f2327e6b2c1d08d71b8622 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 21 Aug 2026 12:35:01 +0700 Subject: [PATCH 2/2] fix(csv): pick fallback delimiter by content, not hardcoded semicolon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header-only fallback added for issue #15 hardcoded semicolon as the separator when csv.Sniffer can't establish one from a single line. But Wordstat also exports tab-delimited views (see "Fix the CSV sniffer's tab delimiter"), so a tab-delimited header-only export still collapsed into one column with embedded tabs — the exact silent-schema-corruption bug issue #15 was filed about, reproduced for the tab case. - choose the fallback delimiter by counting ';' vs '\t' occurrences in the header line instead of hardcoding ';' - extend the unparsed-delimiter header guard to reject both ';' and '\t' - add regression tests for the tab-delimited header-only case and for the guard rejecting an unparsed tab - bring CLAUDE.md's csv_io.py section in line with the actual encoding (UTF-8 with BOM, cp1251 as fallback) and line terminator (lone CR, normalized by Path.read_text's universal newlines) per issue #15 item 3 --- CLAUDE.md | 22 ++++++++++++++++------ src/wordstat/csv_io.py | 33 ++++++++++++++++++++++++++------- tests/test_csv_io.py | 26 ++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d32bfb6..6e66970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,12 +259,22 @@ ruff check . каталоге) — в блоке про `collector.py` выше. - **`csv_io.py`** — парсинг только что скачанных CSV. Кодировка - автоопределяется перебором (`utf-8-sig`, `utf-8`, `cp1251` — Wordstat - экспортирует в cp1251), диалект — через `csv.Sniffer` с фолбэком на - `excel`. Заголовки валидируются (непустые, уникальные), но их конкретные - названия/язык — нет: они локализованы Wordstat и сохраняются как есть. - Пустые строки (все значения — пробелы) отбрасываются. Значения на этом - слое остаются строками. + автоопределяется перебором (`utf-8-sig`, `utf-8`, `cp1251`) — на живых + прогонах Вордстат отдаёт **UTF-8 с BOM**, `cp1251` остаётся фолбэком для + более старых/нестандартных экспортов, а не основным форматом. Терминатор + строк у некоторых видов — одиночный `\r` (CR), не `\r\n`; `Path.read_text` + нормализует это сам через universal newlines, отдельной обработки не + требуется. Диалект определяется через `csv.Sniffer` (разделители + `;`, `,`, `\t`), а на однострочном (только заголовок, без данных) CSV, + где `Sniffer` не может ничего вывести из образца, и при `csv.Error` — + фолбэк не на `excel`/запятую, а на реально встречающийся у Вордстата + разделитель (`;` или `\t`, выбирается по тому, какой чаще встречается в + строке заголовка). Заголовки валидируются (непустые, уникальные, без + оставшегося неразобранного `;`/`\t` — иначе `CsvFormatError`, а не молча + испорченная схема колонок), но их конкретные названия/язык — нет: они + локализованы Wordstat и сохраняются как есть. Пустые строки (все + значения — пробелы) отбрасываются. Значения на этом слое остаются + строками. - **`dtypes.py`** — вывод типов колонок. Типизация идёт **по значениям, а не по именам заголовков**: Wordstat локализует заголовки и переименовывает их diff --git a/src/wordstat/csv_io.py b/src/wordstat/csv_io.py index 9b4fb2b..d4f7ddf 100644 --- a/src/wordstat/csv_io.py +++ b/src/wordstat/csv_io.py @@ -14,12 +14,15 @@ _ENCODINGS = ("utf-8-sig", "utf-8", "cp1251") -class _SemicolonDialect(csv.excel): - """Fallback dialect for Wordstat's known separator.""" +class _FallbackDialect(csv.excel): + """Fallback dialect for a Wordstat separator Sniffer couldn't confirm.""" delimiter = ";" +_FALLBACK_DELIMITERS = (";", "\t") + + def parse_wordstat_csv(path: Path, view: WordstatView) -> CsvDataset: """Decode a Wordstat CSV while preserving its localized column names. @@ -52,14 +55,30 @@ def _read_text(path: Path) -> str: def _detect_dialect(text: str) -> csv.Dialect: # Sniffer cannot establish a delimiter from a header-only export. Do not # let its Excel fallback turn commas inside a localized header into - # columns; Wordstat's separator is semicolon. - if len(text.splitlines()) < 2: - return _SemicolonDialect() + # columns; Wordstat uses either semicolon or tab depending on the view + # (see the "Fix the CSV sniffer's tab delimiter" fix), so pick whichever + # of the two known separators actually appears in the header line rather + # than hardcoding one. + lines = text.splitlines() + if len(lines) < 2: + return _fallback_dialect(lines[0] if lines else "") sample = text[:4096] try: return csv.Sniffer().sniff(sample, delimiters=";,\t") except csv.Error: - return _SemicolonDialect() + return _fallback_dialect(lines[0]) + + +def _fallback_dialect(header_line: str) -> csv.Dialect: + delimiter = max(_FALLBACK_DELIMITERS, key=header_line.count) + if header_line.count(delimiter) == 0: + delimiter = ";" + + class _Dialect(_FallbackDialect): + pass + + _Dialect.delimiter = delimiter + return _Dialect() def _validate_headers(fieldnames: Iterable[str | None] | None, path: Path) -> list[str]: @@ -68,7 +87,7 @@ def _validate_headers(fieldnames: Iterable[str | None] | None, path: Path) -> li headers = [header.strip() if header else "" for header in fieldnames] if not all(headers): raise CsvFormatError(f"CSV {path.name} contains an empty header") - if any(";" in header for header in headers): + if any(delimiter in header for header in headers for delimiter in _FALLBACK_DELIMITERS): raise CsvFormatError(f"CSV {path.name} contains an unparsed delimiter in a header") if len(set(headers)) != len(headers): raise CsvFormatError(f"CSV {path.name} contains duplicate headers") diff --git a/tests/test_csv_io.py b/tests/test_csv_io.py index d66f7d1..103e5bd 100644 --- a/tests/test_csv_io.py +++ b/tests/test_csv_io.py @@ -72,6 +72,32 @@ def test_validate_headers_rejects_unparsed_semicolon(tmp_path: Path) -> None: _validate_headers(["first;broken header"], source) +def test_validate_headers_rejects_unparsed_tab(tmp_path: Path) -> None: + source = tmp_path / "report.csv" + + with pytest.raises(CsvFormatError, match="unparsed delimiter"): + _validate_headers(["first\tbroken header"], source) + + +def test_parse_wordstat_csv_reads_header_only_tab_export(tmp_path: Path) -> None: + source = tmp_path / "report.csv" + source.write_text( + "Запросы со словами\tЧисло запросов\t" + "Топ частотных запросов «новогодние подарки», 20.07.2026 — 20.08.2026, Россия, все устройства", + encoding="utf-8", + ) + + dataset = parse_wordstat_csv(source, WordstatView.TOP_POPULAR) + + assert dataset.headers == [ + "Запросы со словами", + "Число запросов", + "Топ частотных запросов «новогодние подарки», 20.07.2026 — 20.08.2026, Россия, все устройства", + ] + assert all("\t" not in header for header in dataset.headers) + assert dataset.rows == [] + + def test_parse_wordstat_csv_keeps_normal_multiline_export_working(tmp_path: Path) -> None: source = tmp_path / "report.csv" source.write_text("Запрос;Число запросов\nчай;5\nкофе;3\n", encoding="utf-8")