All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m18s
CI / backend-tests (pull_request) Successful in 6m33s
EPIC2 macro-ingest: региональный (Свердловская обл. ОКТМО 65 / ЕКБ) скрейпер Росстата в унифицированную macro_indicator (м.123). Зеркалит форму cbr_macro: pure-парсеры + тонкий httpx, Celery-таска с SAVEPOINT-per-row upsert (CAST not ::, ON CONFLICT по PK indicator_type/region/obs_date). ЕМИСС/fedstat.ru (SDMX) за WAF (hard-403 на всех путях из dev-IP) -> ИПЦ/доходы/ СМР-цены пока недоступны (документировано в коде). rosstat.gov.ru/opendata (стандарт 4.0) WAF-free -> приземляет демографию population_total (sverdl 4.32M / ekb 1.41M, §7.11). - services/scrapers/rosstat_emiss.py — fetch+parse open-data (registry->meta->data CSV) - workers/tasks/rosstat_macro_sync.py — Celery upsert (source=rosstat) - beat: rosstat-macro-sync-monthly; include в celery_app - site_finder/macro.py: population_total -> region-aware default sverdl (additive) - tests: 18 offline-тестов парсера + контракта таски Без миграции (вписано в существующую схему), без новых зависимостей. Refs #946.
173 lines
8.1 KiB
Python
173 lines
8.1 KiB
Python
"""Тесты pure-парсеров Росстат-скрейпера (offline, без живой сети).
|
||
|
||
Покрывают: разбор meta.csv (URL данных + год), парсинг значений (запятая-десятичный,
|
||
разделители тысяч), извлечение целевых ОКТМО-строк из data-CSV (Свердловская обл. /
|
||
ЕКБ), фоллбэк года из meta, дедуп по (region, obs_date), graceful на пустом /
|
||
без-нужных-колонок CSV. Фикстуры — из реального формата open-data Росстата 4.0
|
||
(verified live 2026-06-07: датасет 7708234640-population).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.services.scrapers.rosstat_emiss import (
|
||
OKTMO_EKATERINBURG,
|
||
OKTMO_SVERDLOVSK_OBLAST,
|
||
POPULATION_SPEC,
|
||
DatasetSpec,
|
||
RegionTarget,
|
||
_parse_value,
|
||
parse_dataset_rows,
|
||
parse_meta_data_url,
|
||
parse_meta_modified_year,
|
||
)
|
||
|
||
# Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных —
|
||
# property 'data-<ts>-structure-<ts>.csv' → абсолютный URL CSV-данных.
|
||
FIXTURE_META = (
|
||
"property,value\n"
|
||
"standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n"
|
||
'identifier,7708234640-population\n'
|
||
'title,"Численность постоянного населения"\n'
|
||
"created,20180905\n"
|
||
"modified,20140919\n"
|
||
"format,CSV\n"
|
||
"data-20140919T1709-structure-20141013T1710.csv,"
|
||
"https://rosstat.gov.ru/opendata/7708234640-population/"
|
||
"data-20140919T1709-structure-20141013T1710.csv\n"
|
||
"structure-20141013T1710.csv,"
|
||
"https://rosstat.gov.ru/opendata/7708234640-population/structure-20141013T1710.csv\n"
|
||
)
|
||
|
||
# Реальный shape data-CSV (population): header + муниципальные строки, ОКТМО в 'kode',
|
||
# значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые.
|
||
FIXTURE_POPULATION_DATA = (
|
||
"number,year,kode,region,municipalities,total,urban,rural\n"
|
||
'1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,'
|
||
"446406,209929,236477\n"
|
||
"2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области,"
|
||
"4320677,3500000,820677\n"
|
||
"3,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,1412346,0\n"
|
||
"4,2014,657013640011000,Свердловская область,Верх-Исетский район,201817,201817,0\n"
|
||
)
|
||
|
||
|
||
def test_parse_meta_data_url() -> None:
|
||
"""meta.csv → URL последнего data-файла."""
|
||
url = parse_meta_data_url(FIXTURE_META)
|
||
assert url == (
|
||
"https://rosstat.gov.ru/opendata/7708234640-population/"
|
||
"data-20140919T1709-structure-20141013T1710.csv"
|
||
)
|
||
|
||
|
||
def test_parse_meta_modified_year() -> None:
|
||
"""meta.csv 'modified' (YYYYMMDD) → год."""
|
||
assert parse_meta_modified_year(FIXTURE_META) == 2014
|
||
|
||
|
||
def test_parse_meta_missing_data_url() -> None:
|
||
"""meta без строки данных → None (не бросает)."""
|
||
assert parse_meta_data_url("property,value\nidentifier,foo\n") is None
|
||
|
||
|
||
def test_parse_value_comma_decimal() -> None:
|
||
"""Запятая как десятичный разделитель: '1,5' → Decimal('1.5')."""
|
||
assert _parse_value("1,5") == Decimal("1.5")
|
||
|
||
|
||
def test_parse_value_thousands_separator() -> None:
|
||
"""Пробелы-разделители тысяч вырезаются: '4 320 677' → Decimal('4320677')."""
|
||
assert _parse_value("4 320 677") == Decimal("4320677")
|
||
# узкий неразрывный пробел (Росстат иногда так)
|
||
assert _parse_value("1 412 346") == Decimal("1412346")
|
||
|
||
|
||
def test_parse_value_empty_and_dash() -> None:
|
||
"""Пустое / прочерк / многоточие → None (строка пропускается)."""
|
||
assert _parse_value("") is None
|
||
assert _parse_value("-") is None
|
||
assert _parse_value("…") is None
|
||
assert _parse_value("не число") is None
|
||
|
||
|
||
def test_parse_dataset_targets_only() -> None:
|
||
"""Из data-CSV извлекаются ТОЛЬКО целевые ОКТМО (субъект + ЕКБ), не районы/др.регионы."""
|
||
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
|
||
by_region = {r.region: r for r in rows}
|
||
assert set(by_region) == {"sverdl", "ekb"}
|
||
# Адыгея и Верх-Исетский район — НЕ среди целей
|
||
assert all(r.region in {"sverdl", "ekb"} for r in rows)
|
||
|
||
|
||
def test_parse_dataset_values_and_dates() -> None:
|
||
"""Значения и obs_date (1 января года из колонки 'year') корректны."""
|
||
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
|
||
by_region = {r.region: r for r in rows}
|
||
assert by_region["sverdl"].value == Decimal("4320677")
|
||
assert by_region["sverdl"].obs_date == date(2014, 1, 1)
|
||
assert by_region["ekb"].value == Decimal("1412346")
|
||
assert by_region["ekb"].obs_date == date(2014, 1, 1)
|
||
# тип / единица из spec
|
||
assert by_region["sverdl"].indicator_type == "population_total"
|
||
assert by_region["sverdl"].unit == "чел"
|
||
|
||
|
||
def test_parse_dataset_fallback_year_when_no_year_col() -> None:
|
||
"""Если в spec year_col_header=None — год берётся из fallback_year (meta modified)."""
|
||
spec_no_year = DatasetSpec(
|
||
code=POPULATION_SPEC.code,
|
||
indicator_type="population_total",
|
||
unit="чел",
|
||
value_col_header="total",
|
||
oktmo_col_header="kode",
|
||
year_col_header=None, # год не из данных
|
||
targets=(RegionTarget(oktmo=OKTMO_SVERDLOVSK_OBLAST, region="sverdl"),),
|
||
)
|
||
rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, spec_no_year, fallback_year=2014)
|
||
assert len(rows) == 1
|
||
assert rows[0].obs_date == date(2014, 1, 1)
|
||
|
||
|
||
def test_parse_dataset_dedup_last_wins() -> None:
|
||
"""Дубль строки по (region, obs_date) схлопывается — последняя выигрывает."""
|
||
data = (
|
||
"number,year,kode,region,municipalities,total,urban,rural\n"
|
||
"1,2014,6500000000,Свердловская область,МО,4320677,0,0\n"
|
||
"2,2014,6500000000,Свердловская область,МО,4400000,0,0\n" # дубль ОКТМО+год
|
||
)
|
||
rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
|
||
assert len(rows) == 1
|
||
assert rows[0].value == Decimal("4400000")
|
||
|
||
|
||
def test_parse_dataset_skips_unparsable_value() -> None:
|
||
"""Строка с непарсящимся значением пропускается, валидные остаются."""
|
||
data = (
|
||
"number,year,kode,region,municipalities,total,urban,rural\n"
|
||
"1,2014,6500000000,Свердловская область,МО,-,0,0\n" # value '-'
|
||
"2,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,0,0\n"
|
||
)
|
||
rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
|
||
assert len(rows) == 1
|
||
assert rows[0].region == "ekb"
|
||
|
||
|
||
def test_parse_dataset_missing_columns_graceful() -> None:
|
||
"""CSV без нужных колонок (kode/total) → [] (+warning), не бросает."""
|
||
data = "foo,bar\n1,2\n3,4\n"
|
||
assert parse_dataset_rows(data, POPULATION_SPEC, fallback_year=2014) == []
|
||
|
||
|
||
def test_parse_dataset_empty_graceful() -> None:
|
||
"""Пустой CSV → []."""
|
||
assert parse_dataset_rows("", POPULATION_SPEC, fallback_year=2014) == []
|
||
|
||
|
||
def test_oktmo_constants_used_in_spec() -> None:
|
||
"""Спека population таргетит именно субъект-ОКТМО и ЕКБ-ОКТМО (контракт ТЗ §7.11)."""
|
||
oktmos = {t.oktmo for t in POPULATION_SPEC.targets}
|
||
assert OKTMO_SVERDLOVSK_OBLAST in oktmos
|
||
assert OKTMO_EKATERINBURG in oktmos
|