feat(mera): слой ДТП (dtp-stat/ГИБДД) в PostGIS + радиусные агрегаты
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m17s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m17s
Первая локационная фича вне OSM. Открытая выгрузка по Свердловской области — GeoJSON, 5 МБ zip / ~71 МБ распакованных, 31 253 ДТП за 2015-01..2026-01, из них ~15 тыс. с адресом в Екатеринбурге. Потоковый парс через ijson, файл целиком в память не читается. Атрибуты: тяжесть, категория, погибшие/раненые, освещение, погода, объекты УДС рядом. Агрегаты в радиусе — bbox-префильтр + ST_DWithin(geom::geography), с функциональным GIST по (geom::geography): обычный GIST по geometry планировщик под такой предикат не берёт (замеры мигр. 270 и 134). ПДн НЕ СОХРАНЯЕМ: в vehicles[].participants[] лежат пол, роль и нарушения физлиц. Ни колонкой, ни в raw-jsonb — зафиксировано в миграции, докстринге и покрыто тестом. Битые координаты источника режутся на парсе: из 31 253 записей 86 лежат вне региона, в том числе 10 ровно в (0,0) и такие значения, как (1.0, 1.0) и (41.0, 47.0). Слой кормит агрегаты локации, пускать туда мусор нельзя. Санити-рамка намеренно шире официальных границ области (55.0-63.5 N, 56.0-67.5 E), чтобы не срезать легитимный север (~63°). dtp_index пока не подключён к API — потребитель отдельным PR. Дамп источника заморожен на Last-Modified 2026-02-26: зафиксировано в докстринге, чтобы пустая дельта не читалась как баг загрузчика. Миграции 294 (таблица + индексы) и 295 (сид расписания, enabled=false, interval_days 30). Новая зависимость: ijson.
This commit is contained in:
parent
1441e189c8
commit
3a73c9582c
10 changed files with 1122 additions and 0 deletions
157
tradein-mvp/backend/app/services/dtp_index.py
Normal file
157
tradein-mvp/backend/app/services/dtp_index.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Радиусные агрегаты ДТП вокруг точки — `dtp_incidents` (#3410).
|
||||
|
||||
Отдельный модуль, не расширение `location_index.py`: та модель считает ЦЕНОВОЙ индекс
|
||||
(медиана ₽/м² локально vs по городу) с фиксированным bbox продукт-ядра города — другая
|
||||
предметная область и другой контракт результата (status/coverage у location_index
|
||||
завязан на `regions_mod`/`bbox_product_core`, ДТП покрывает всю область без city-bbox
|
||||
ограничения источника). Общее — только сам паттерн запроса (bbox-префильтр +
|
||||
`ST_DWithin(geom::geography, …)`) и dtp_incidents/osm_poi_ekb_local roднит один и тот же
|
||||
"пустая таблица -> unavailable" graceful fallback (см. `_fetch_nearby_poi` в
|
||||
location_index.py) — переиспользован здесь буквально.
|
||||
|
||||
Bbox-префильтр здесь, в отличие от location_index (там фиксированный bbox city-ядра),
|
||||
строится ВОКРУГ ТОЧКИ по радиусу (`_bbox_from_point`) — dtp_incidents не ограничена
|
||||
одним городом, фиксированного bbox покрытия нет. Дешёвый lat/lon BETWEEN перед
|
||||
ST_DWithin — тот же приём, что в шаблоне recon_geo.md п.3.
|
||||
|
||||
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Радиус по умолчанию для "фактора безопасности" района — сопоставим по порядку
|
||||
# величины с DEFAULT_POI_RADIUS_M=1200 в location_index.py (тот же масштаб "пешком от
|
||||
# дома"), но не импортируется оттуда: разные предметные области, совпадение числа
|
||||
# не должно создавать ложную связь между модулями.
|
||||
DEFAULT_DTP_RADIUS_M = 1000
|
||||
|
||||
# Сколько лет ДТП назад учитывать по умолчанию — источник копит данные с 2015 (см.
|
||||
# докстринг dtp_stat_loader.py), но "фактор безопасности" района должен отражать
|
||||
# ТЕКУЩУЮ ситуацию, а не десятилетнюю историю.
|
||||
DEFAULT_YEARS_LOOKBACK = 5
|
||||
|
||||
_METERS_PER_DEGREE_LAT = 111_320.0
|
||||
|
||||
|
||||
def _bbox_from_point(lat: float, lon: float, radius_m: int) -> tuple[float, float, float, float]:
|
||||
"""(south, north, west, east) — bbox вокруг точки радиусом radius_m (приближённо)."""
|
||||
lat_delta = radius_m / _METERS_PER_DEGREE_LAT
|
||||
lon_scale = math.cos(math.radians(lat))
|
||||
# Защита от вырождения делителя у полюсов — для Свердловской обл. (~56-61° с.ш.)
|
||||
# cos никогда не приближается к нулю, но защита дешёвая и делает функцию safe везде.
|
||||
lon_delta = radius_m / (_METERS_PER_DEGREE_LAT * max(lon_scale, 0.01))
|
||||
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
|
||||
|
||||
|
||||
class DtpAreaStats(BaseModel):
|
||||
"""Результат compute_dtp_stats."""
|
||||
|
||||
status: str # "ok" | "unavailable" (dtp_incidents пуста — рефреш ещё не запускался)
|
||||
radius_m: int
|
||||
years: int
|
||||
incidents_count: int
|
||||
severe_count: int
|
||||
dead: int
|
||||
injured: int
|
||||
|
||||
|
||||
_DTP_STATS_SQL = text(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS incidents_count,
|
||||
count(*) FILTER (WHERE severity = 'Тяжёлый') AS severe_count,
|
||||
COALESCE(sum(dead), 0) AS dead_total,
|
||||
COALESCE(sum(injured), 0) AS injured_total
|
||||
FROM dtp_incidents
|
||||
WHERE dtp_at >= NOW() - make_interval(years => CAST(:years AS integer))
|
||||
AND lat BETWEEN CAST(:bbox_south AS double precision)
|
||||
AND CAST(:bbox_north AS double precision)
|
||||
AND lon BETWEEN CAST(:bbox_west AS double precision)
|
||||
AND CAST(:bbox_east AS double precision)
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
|
||||
CAST(:radius_m AS double precision)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def compute_dtp_stats(
|
||||
db: Any,
|
||||
lat: float,
|
||||
lon: float,
|
||||
*,
|
||||
radius_m: int = DEFAULT_DTP_RADIUS_M,
|
||||
years: int = DEFAULT_YEARS_LOOKBACK,
|
||||
) -> DtpAreaStats:
|
||||
"""Посчитать агрегаты ДТП в радиусе `radius_m` вокруг точки за последние `years` лет.
|
||||
|
||||
Graceful fallback: `dtp_incidents` пуста (рефреш ещё не запускался на этом
|
||||
окружении, source dormant по умолчанию — миграция 295) -> status="unavailable",
|
||||
нули НЕ фабрикуются. Ноль инцидентов при непустой таблице — легитимный "ok"
|
||||
результат (в радиусе правда ничего не было), отличается статусом от "нет данных".
|
||||
"""
|
||||
total = db.execute(text("SELECT count(*) FROM dtp_incidents")).scalar() or 0
|
||||
if total == 0:
|
||||
logger.warning(
|
||||
"dtp_index: dtp_incidents пуста (рефреш ещё не запускался на этом "
|
||||
"окружении) — unavailable, без сфабрикованных нулей"
|
||||
)
|
||||
return DtpAreaStats(
|
||||
status="unavailable",
|
||||
radius_m=radius_m,
|
||||
years=years,
|
||||
incidents_count=0,
|
||||
severe_count=0,
|
||||
dead=0,
|
||||
injured=0,
|
||||
)
|
||||
|
||||
bbox_south, bbox_north, bbox_west, bbox_east = _bbox_from_point(lat, lon, radius_m)
|
||||
row = (
|
||||
db.execute(
|
||||
_DTP_STATS_SQL,
|
||||
{
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"radius_m": radius_m,
|
||||
"years": years,
|
||||
"bbox_south": bbox_south,
|
||||
"bbox_north": bbox_north,
|
||||
"bbox_west": bbox_west,
|
||||
"bbox_east": bbox_east,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return DtpAreaStats(
|
||||
status="ok",
|
||||
radius_m=radius_m,
|
||||
years=years,
|
||||
incidents_count=0,
|
||||
severe_count=0,
|
||||
dead=0,
|
||||
injured=0,
|
||||
)
|
||||
|
||||
return DtpAreaStats(
|
||||
status="ok",
|
||||
radius_m=radius_m,
|
||||
years=years,
|
||||
incidents_count=int(row["incidents_count"] or 0),
|
||||
severe_count=int(row["severe_count"] or 0),
|
||||
dead=int(row["dead_total"] or 0),
|
||||
injured=int(row["injured_total"] or 0),
|
||||
)
|
||||
281
tradein-mvp/backend/app/services/dtp_stat_loader.py
Normal file
281
tradein-mvp/backend/app/services/dtp_stat_loader.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""dtp-stat.ru loader: слой ДТП по Свердловской обл. в `dtp_incidents` (#3410).
|
||||
|
||||
CONTEXT: карточка района МЕРЫ хочет "фактор безопасности" — сколько ДТП/тяжёлых/
|
||||
погибших/раненых было рядом за последние годы. dtp-stat.ru агрегирует открытые данные
|
||||
ГИБДД в GeoJSON по регионам, без auth.
|
||||
|
||||
ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — ZIP
|
||||
(~5 МБ) с одним файлом `sverdlovskaia-oblast.geojson` (~71 МБ распакованного,
|
||||
FeatureCollection, ~31k Point-фич). Проверено живьём 08.09.2026: HTTP 200,
|
||||
5 047 685 байт. Домен обычный (не RU-gov минцифровский сертификат) — verify=False
|
||||
НЕ ставим, лишний.
|
||||
|
||||
МОНИТОРИНГ ПРОТУХАНИЯ (не реализован в этом PR — зафиксировано, чтобы следующий не
|
||||
считал пустую дельту багом): на момент проверки 08.09.2026 дамп источника заморожен,
|
||||
Last-Modified 26.02.2026. TRUNCATE+INSERT переливает те же ~31k строк каждый рефреш —
|
||||
это ожидаемо, а не признак сломанного парсинга. Если нужен freshness-монитор — см.
|
||||
паттерн `recon_macro.md` (СберИндекс), здесь не сделан осознанно (вне скоупа #3410).
|
||||
|
||||
СТРИМИНГ (обязательно, файл большой): `json.load()` целиком держал бы ~71 МБ + объектный
|
||||
граф в памяти разом. Используем `ijson.items(stream, "features.item")` поверх
|
||||
файлового объекта из `zipfile.ZipFile.open(name)` — постоянная память вне зависимости
|
||||
от размера файла, каждая feature обрабатывается и отбрасывается по одной.
|
||||
|
||||
ПДн — НЕ СОХРАНЯЕМ (осознанное решение, зафиксировано и в 294_dtp_incidents.sql):
|
||||
`properties.vehicles[].participants[]` источника несёт пол/роль/нарушения физлиц —
|
||||
это персональные данные конкретных людей, продукту не нужны и не разрешены. Парсер
|
||||
(`_parse_feature`) НИКОГДА не читает ключи `vehicles`/`participants` — ни в raw-jsonb,
|
||||
ни отдельной колонкой. Из `properties` берём только неперсональные агрегаты и атрибуты
|
||||
происшествия (see DtpIncidentRow).
|
||||
|
||||
Идемпотентность: TRUNCATE + bulk INSERT в одной транзакции (тот же паттерн, что
|
||||
`app/tasks/osm_poi_ekb_refresh.py` — полная замена, не upsert; повторный прогон с тем
|
||||
же дампом даёт тот же результат).
|
||||
|
||||
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||
Массивы (`weather`, `nearby`, тип `text[]`) — psycopg v3 адаптирует Python `list[str]`
|
||||
в `text[]` напрямую через `CAST(:x AS text[])` (тот же идиом, что
|
||||
`app/tasks/deactivate_stale_avito.py`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
import httpx
|
||||
import ijson
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SRC_URL = "https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip"
|
||||
DOWNLOAD_TIMEOUT_SEC = 180
|
||||
INSERT_CHUNK_SIZE = 2000
|
||||
|
||||
# properties.datetime — "2015-01-01 03:00:00", без явной таймзоны в источнике.
|
||||
# Храним как naive → колонка timestamptz получит его в timezone сессии БД (обычно UTC).
|
||||
# Источник не публикует TZ, поэтому точная привязка к МСК не гарантирована — для
|
||||
# радиусных агрегатов по годам (dtp_index.py) это несущественно.
|
||||
_DT_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DtpIncidentRow:
|
||||
"""Одна строка ДТП — ТОЛЬКО неперсональные атрибуты (см. докстринг модуля)."""
|
||||
|
||||
source_id: str
|
||||
dtp_at: datetime | None
|
||||
category: str | None
|
||||
severity: str | None
|
||||
dead: int | None
|
||||
injured: int | None
|
||||
address: str | None
|
||||
light: str | None
|
||||
weather: list[str] | None
|
||||
nearby: list[str] | None
|
||||
lat: float
|
||||
lon: float
|
||||
|
||||
|
||||
def _parse_datetime(raw: Any) -> datetime | None:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(raw.strip(), _DT_FMT)
|
||||
except ValueError:
|
||||
logger.warning("dtp_stat_loader: не распарсен datetime %r", raw)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_str_list(raw: Any) -> list[str] | None:
|
||||
if not isinstance(raw, list) or not raw:
|
||||
return None
|
||||
return [str(item) for item in raw if item is not None]
|
||||
|
||||
|
||||
def _parse_int(raw: Any) -> int | None:
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
if isinstance(raw, int):
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
# Санити-рамка координат. Выгрузка dtp-stat по Свердловской области содержит битые
|
||||
# точки: на реальном дампе 2026-02-26 из 31 253 записей 74 лежат вне широт региона,
|
||||
# в том числе 10 ровно в (0.0, 0.0) и такие значения, как (1.0, 1.0) и (41.0, 47.0).
|
||||
# Пускать их в geo-таблицу нельзя: слой кормит радиусные агрегаты локации.
|
||||
# Рамка намеренно шире официальных границ области (~56.0-62.3 N, ~57.2-66.2 E),
|
||||
# чтобы не срезать легитимные приграничные точки.
|
||||
REGION_LAT_RANGE = (55.0, 63.5)
|
||||
REGION_LON_RANGE = (56.0, 67.5)
|
||||
|
||||
|
||||
def _coords_plausible(lat: float, lon: float) -> bool:
|
||||
"""Точка похожа на реальное ДТП в регионе выгрузки, а не на мусор источника."""
|
||||
if lat == 0.0 and lon == 0.0:
|
||||
return False
|
||||
if not (REGION_LAT_RANGE[0] <= lat <= REGION_LAT_RANGE[1]):
|
||||
return False
|
||||
return REGION_LON_RANGE[0] <= lon <= REGION_LON_RANGE[1]
|
||||
|
||||
|
||||
def _parse_feature(feature: dict[str, Any]) -> DtpIncidentRow | None:
|
||||
"""Чистая функция: одна GeoJSON Feature -> DtpIncidentRow, либо None (пропуск).
|
||||
|
||||
НЕ читает `properties.vehicles` / `properties.participants` — намеренно (ПДн,
|
||||
см. докстринг модуля). Пропускает фичи без валидной Point-геометрии/id, а также
|
||||
с координатами вне санити-рамки региона (см. `_coords_plausible`).
|
||||
"""
|
||||
geometry = feature.get("geometry") or {}
|
||||
if geometry.get("type") != "Point":
|
||||
return None
|
||||
coords = geometry.get("coordinates")
|
||||
if not isinstance(coords, list) or len(coords) < 2:
|
||||
return None
|
||||
try:
|
||||
lon, lat = float(coords[0]), float(coords[1])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not _coords_plausible(lat, lon):
|
||||
return None
|
||||
|
||||
props = feature.get("properties") or {}
|
||||
source_id = props.get("id")
|
||||
if source_id is None:
|
||||
return None
|
||||
|
||||
return DtpIncidentRow(
|
||||
source_id=str(source_id),
|
||||
dtp_at=_parse_datetime(props.get("datetime")),
|
||||
category=props.get("category") if isinstance(props.get("category"), str) else None,
|
||||
severity=props.get("severity") if isinstance(props.get("severity"), str) else None,
|
||||
dead=_parse_int(props.get("dead_count")),
|
||||
injured=_parse_int(props.get("injured_count")),
|
||||
address=props.get("address") if isinstance(props.get("address"), str) else None,
|
||||
light=props.get("light") if isinstance(props.get("light"), str) else None,
|
||||
weather=_parse_str_list(props.get("weather")),
|
||||
nearby=_parse_str_list(props.get("nearby")),
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
|
||||
|
||||
def iter_incidents(stream: IO[bytes]) -> Iterator[DtpIncidentRow]:
|
||||
"""Потоковый парс GeoJSON FeatureCollection -> DtpIncidentRow, по одной фиче за раз.
|
||||
|
||||
`stream` — бинарный файловый объект (из `zipfile.ZipFile.open()` или обычного
|
||||
`open(path, "rb")`). Использует `ijson.items(..., "features.item")` — постоянная
|
||||
память, НЕ читает файл целиком.
|
||||
"""
|
||||
for feature in ijson.items(stream, "features.item"):
|
||||
row = _parse_feature(feature)
|
||||
if row is not None:
|
||||
yield row
|
||||
|
||||
|
||||
def _open_geojson_in_zip(zf: zipfile.ZipFile) -> IO[bytes]:
|
||||
names = [n for n in zf.namelist() if n.lower().endswith(".geojson")]
|
||||
if not names:
|
||||
raise ValueError(f"zip не содержит .geojson записей: {zf.namelist()!r}")
|
||||
return zf.open(names[0])
|
||||
|
||||
|
||||
def download_dtp_zip(*, client: httpx.Client) -> bytes:
|
||||
"""Скачать ZIP dtp-stat.ru. Открытые данные, без auth/PII — стандартный verify."""
|
||||
resp = client.get(SRC_URL, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
_TRUNCATE_SQL = text("TRUNCATE dtp_incidents")
|
||||
|
||||
_INSERT_SQL = text(
|
||||
"""
|
||||
INSERT INTO dtp_incidents
|
||||
(source_id, dtp_at, category, severity, dead, injured, address, light,
|
||||
weather, nearby, lat, lon, geom)
|
||||
VALUES
|
||||
(CAST(:source_id AS text), CAST(:dtp_at AS timestamptz),
|
||||
CAST(:category AS text), CAST(:severity AS text),
|
||||
CAST(:dead AS integer), CAST(:injured AS integer),
|
||||
CAST(:address AS text), CAST(:light AS text),
|
||||
CAST(:weather AS text[]), CAST(:nearby AS text[]),
|
||||
CAST(:lat AS double precision), CAST(:lon AS double precision),
|
||||
ST_SetSRID(ST_MakePoint(CAST(:lon AS double precision), CAST(:lat AS double precision)),
|
||||
4326))
|
||||
ON CONFLICT (source_id) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _row_params(row: DtpIncidentRow) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": row.source_id,
|
||||
"dtp_at": row.dtp_at,
|
||||
"category": row.category,
|
||||
"severity": row.severity,
|
||||
"dead": row.dead,
|
||||
"injured": row.injured,
|
||||
"address": row.address,
|
||||
"light": row.light,
|
||||
"weather": row.weather,
|
||||
"nearby": row.nearby,
|
||||
"lat": row.lat,
|
||||
"lon": row.lon,
|
||||
}
|
||||
|
||||
|
||||
def load_dtp_incidents(
|
||||
db: Session,
|
||||
*,
|
||||
src_path: str | Path | None = None,
|
||||
client: httpx.Client | None = None,
|
||||
chunk_size: int = INSERT_CHUNK_SIZE,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, int]:
|
||||
"""TRUNCATE dtp_incidents; потоковый парс + bulk INSERT из ZIP dtp-stat.ru.
|
||||
|
||||
`src_path` — локальный ZIP (тесты/ручной прогон); если None — качаем `SRC_URL`
|
||||
через `client` (обязателен параметром, чтобы был чем подменить в тестах;
|
||||
создаётся временный `httpx.Client()` если не передан).
|
||||
|
||||
`dry_run=True` — парсит и считает строки, НЕ трогает БД (ни TRUNCATE, ни INSERT).
|
||||
|
||||
Не коммитит — коммитит caller (app/tasks/dtp_stat_refresh.py).
|
||||
"""
|
||||
owns_client = client is None
|
||||
client = client or httpx.Client()
|
||||
try:
|
||||
if src_path is not None:
|
||||
with zipfile.ZipFile(src_path) as zf, _open_geojson_in_zip(zf) as stream:
|
||||
rows = list(iter_incidents(stream))
|
||||
else:
|
||||
data = download_dtp_zip(client=client)
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf, _open_geojson_in_zip(zf) as stream:
|
||||
rows = list(iter_incidents(stream))
|
||||
finally:
|
||||
if owns_client:
|
||||
client.close()
|
||||
|
||||
parsed = len(rows)
|
||||
inserted = 0
|
||||
|
||||
if not dry_run:
|
||||
db.execute(_TRUNCATE_SQL)
|
||||
for i in range(0, len(rows), chunk_size):
|
||||
chunk = rows[i : i + chunk_size]
|
||||
result = db.execute(_INSERT_SQL, [_row_params(r) for r in chunk])
|
||||
inserted += result.rowcount or 0
|
||||
|
||||
result_counts = {"parsed": parsed, "inserted": inserted}
|
||||
logger.info("dtp_stat_loader load DONE (dry_run=%s): %s", dry_run, result_counts)
|
||||
return result_counts
|
||||
|
|
@ -544,6 +544,19 @@ async def _job_osm_poi_ekb_refresh(
|
|||
)
|
||||
|
||||
|
||||
# ── dtp_stat_refresh — sync ZIP-скачивание+парс ДТП в executor ────────────────
|
||||
async def _job_dtp_stat_refresh(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.tasks.dtp_stat_refresh import run_dtp_stat_refresh
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: run_dtp_stat_refresh(db, run_id=run_id, params=params),
|
||||
)
|
||||
|
||||
|
||||
# ── house_imv_backfill — async Avito-IMV с heartbeat, lifecycle в job ─────────
|
||||
async def _job_house_imv_backfill(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -830,6 +843,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"),
|
||||
"cadastral_geo_match": Handler(_job_cadastral_geo_match, "cadastral_geo_match"),
|
||||
"osm_poi_ekb_refresh": Handler(_job_osm_poi_ekb_refresh, "osm_poi_ekb_refresh"),
|
||||
"dtp_stat_refresh": Handler(_job_dtp_stat_refresh, "dtp_stat_refresh"),
|
||||
"house_imv_backfill": Handler(_job_house_imv_backfill, "house_imv_backfill"),
|
||||
"house_dedup_merge": Handler(_job_house_dedup_merge, "house_dedup_merge"),
|
||||
"domrf_kapremont_load": Handler(_job_domrf_kapremont_load, "domrf_kapremont_load"),
|
||||
|
|
|
|||
119
tradein-mvp/backend/app/tasks/dtp_stat_refresh.py
Normal file
119
tradein-mvp/backend/app/tasks/dtp_stat_refresh.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""ДТП (dtp-stat.ru) refresh — CLI + scheduler run-lifecycle wrapper (#3410).
|
||||
|
||||
Наполняет `dtp_incidents` (пустая при деплое, миграция 294) через
|
||||
`app/services/dtp_stat_loader.load_dtp_incidents` (скачивание ZIP + потоковый парс +
|
||||
TRUNCATE+INSERT). Scheduler source='dtp_stat_refresh' (seed 293, enabled=false —
|
||||
источник заморожен, см. докстринг loader'а; первый прогон ручной).
|
||||
|
||||
Запуск из контейнера:
|
||||
python -m app.tasks.dtp_stat_refresh
|
||||
python -m app.tasks.dtp_stat_refresh --dry-run
|
||||
python -m app.tasks.dtp_stat_refresh --src-path /path/to/local.zip
|
||||
|
||||
psycopg v3: `CAST(:x AS type)`, никогда `:x::type`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services import scrape_runs as runs_mod
|
||||
from app.services.dtp_stat_loader import load_dtp_incidents
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DtpStatRefreshResult:
|
||||
parsed: int = 0
|
||||
inserted: int = 0
|
||||
duration_sec: float = field(default=0.0)
|
||||
|
||||
def to_counters(self) -> dict[str, int]:
|
||||
return {
|
||||
"parsed": self.parsed,
|
||||
"inserted": self.inserted,
|
||||
"duration_sec": int(self.duration_sec),
|
||||
}
|
||||
|
||||
|
||||
def run_dtp_stat_refresh(db: Session, *, run_id: int, params: dict) -> DtpStatRefreshResult:
|
||||
"""Run-lifecycle wrapper для scheduler'а (source='dtp_stat_refresh') и ручного прогона.
|
||||
|
||||
params:
|
||||
dry_run — bool, по умолчанию False. True: парсит и считает, БД не трогает.
|
||||
src_path — str, локальный путь к ZIP (для отладки/тестов, минуя скачивание).
|
||||
|
||||
Финализирует scrape_runs (mark_done / mark_failed) со счётчиками.
|
||||
"""
|
||||
dry_run = bool(params.get("dry_run", False))
|
||||
src_path = params.get("src_path")
|
||||
|
||||
counters: dict[str, int] = {"parsed": 0, "inserted": 0}
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
|
||||
load_counts = load_dtp_incidents(db, src_path=src_path, dry_run=dry_run)
|
||||
if not dry_run:
|
||||
db.commit()
|
||||
|
||||
result = DtpStatRefreshResult(
|
||||
parsed=load_counts["parsed"],
|
||||
inserted=load_counts["inserted"],
|
||||
duration_sec=time.monotonic() - start,
|
||||
)
|
||||
counters = result.to_counters()
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"run_dtp_stat_refresh: run_id=%d DONE dry_run=%s parsed=%d inserted=%d duration=%.1fs",
|
||||
run_id,
|
||||
dry_run,
|
||||
result.parsed,
|
||||
result.inserted,
|
||||
result.duration_sec,
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.exception("run_dtp_stat_refresh: run_id=%d FAILED", run_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||
raise
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--src-path", default=None, help="Локальный ZIP вместо скачивания SRC_URL")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Парсить и считать, не писать в БД")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||
)
|
||||
args = build_parser().parse_args()
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
counts = load_dtp_incidents(db, src_path=args.src_path, dry_run=args.dry_run)
|
||||
if not args.dry_run:
|
||||
db.commit()
|
||||
logger.info("dtp_stat_refresh CLI DONE: dry_run=%s %s", args.dry_run, counts)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
79
tradein-mvp/backend/data/sql/294_dtp_incidents.sql
Normal file
79
tradein-mvp/backend/data/sql/294_dtp_incidents.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
-- 294_dtp_incidents.sql
|
||||
-- ДТП (dtp-stat.ru, первоисточник — открытые данные ГИБДД), Свердловская обл. (#3410).
|
||||
--
|
||||
-- WHY: слой ДТП нужен как радиусный "фактор безопасности" района (по образцу
|
||||
-- osm_poi_ekb_local / 169_osm_poi_ekb_local.sql — та же архитектура: пустая таблица +
|
||||
-- индексы на деплое, наполнение отдельным рефреш-джобом, никакого чтения внешнего
|
||||
-- источника при прогоне миграций).
|
||||
--
|
||||
-- ИСТОЧНИК: https://dtp-stat.ru/media/opendata/sverdlovskaia-oblast.geojson.zip — открытый
|
||||
-- агрегатор данных ГИБДД, без auth. Дамп заморожен (Last-Modified 26.02.2026 на момент
|
||||
-- проверки 08.09.2026) — см. докстринг app/services/dtp_stat_loader.py, пустая дельта на
|
||||
-- рефреше это НЕ баг, а факт источника.
|
||||
--
|
||||
-- ПДн (важно): properties.vehicles[].participants[] источника содержит пол/роль/нарушения
|
||||
-- физлиц-участников ДТП. Эта таблица их СОЗНАТЕЛЬНО НЕ хранит — ни колонкой, ни в jsonb.
|
||||
-- Загрузчик (dtp_stat_loader.py) участников/транспорт из парсинга просто не извлекает.
|
||||
-- Берём только агрегаты (dead/injured count) и не-персональные атрибуты происшествия.
|
||||
--
|
||||
-- ИНДЕКСЫ (урок 270_houses_geog_gist_idx.sql / 134_listings_geom_geography_gist.sql): весь
|
||||
-- код МЕРЫ ходит радиусными запросами через ST_DWithin(geom::geography, …) — обычный GIST
|
||||
-- по geometry планировщик под такой предикат не берёт (прод-замер houses: 70.7 мс на
|
||||
-- bitmap-фильтр всех строк; после функционального индекса по geography — 38 мс на listings).
|
||||
-- Поэтому сразу оба индекса: geometry (на будущее/для прочих операторов) и функциональный
|
||||
-- по (geom::geography) — под фактический предикат dtp_index.py.
|
||||
--
|
||||
-- Идемпотентно: CREATE TABLE/INDEX IF NOT EXISTS. Безопасно перезапускать.
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dtp_incidents (
|
||||
id bigserial PRIMARY KEY,
|
||||
source_id text UNIQUE,
|
||||
dtp_at timestamptz,
|
||||
category text,
|
||||
severity text,
|
||||
dead integer,
|
||||
injured integer,
|
||||
address text,
|
||||
light text,
|
||||
weather text[],
|
||||
nearby text[],
|
||||
lat double precision,
|
||||
lon double precision,
|
||||
geom geometry(Point, 4326),
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE dtp_incidents IS
|
||||
'ДТП по Свердловской обл. (dtp-stat.ru, первоисточник — открытые данные ГИБДД, #3410). '
|
||||
'Пишет app/tasks/dtp_stat_refresh.py (TRUNCATE+INSERT, scheduler source=dtp_stat_refresh, '
|
||||
'seed 293, enabled=false — включение вручную). Читает app/services/dtp_index.py — '
|
||||
'радиусные агрегаты (число ДТП/тяжёлых/погибших/раненых) для карточки района. '
|
||||
'ПДн участников (vehicles/participants: пол, роль, нарушения физлиц) осознанно НЕ '
|
||||
'хранятся — ни колонкой, ни в jsonb; источник даёт их построчно на транспорт/участника, '
|
||||
'загрузчик их не извлекает вовсе.';
|
||||
|
||||
COMMENT ON COLUMN dtp_incidents.source_id IS 'properties.id из dtp-stat GeoJSON — ключ идемпотентности (UNIQUE).';
|
||||
COMMENT ON COLUMN dtp_incidents.dtp_at IS 'properties.datetime, распарсено в timestamptz.';
|
||||
COMMENT ON COLUMN dtp_incidents.category IS 'properties.category — тип ДТП ("Столкновение" и т.п.).';
|
||||
COMMENT ON COLUMN dtp_incidents.severity IS 'properties.severity — тяжесть ("Тяжёлый"/"Лёгкий"/…).';
|
||||
COMMENT ON COLUMN dtp_incidents.dead IS 'properties.dead_count — погибшие, агрегат, без ФИО/пола участников.';
|
||||
COMMENT ON COLUMN dtp_incidents.injured IS 'properties.injured_count — раненые, агрегат.';
|
||||
COMMENT ON COLUMN dtp_incidents.weather IS 'properties.weather — список погодных условий на момент ДТП.';
|
||||
COMMENT ON COLUMN dtp_incidents.nearby IS 'properties.nearby — список объектов УДС/притяжения рядом с местом ДТП.';
|
||||
COMMENT ON COLUMN dtp_incidents.geom IS 'ST_SetSRID(ST_MakePoint(lon, lat), 4326) — строится явно в загрузчике, без триггера (как osm_poi_ekb_local).';
|
||||
|
||||
-- GIST по geometry — на будущее / прочие geometry-операторы.
|
||||
CREATE INDEX IF NOT EXISTS dtp_incidents_geom_idx
|
||||
ON dtp_incidents USING GIST (geom);
|
||||
|
||||
-- Функциональный GIST по (geom::geography) — под фактический предикат
|
||||
-- ST_DWithin(geom::geography, …) в app/services/dtp_index.py (см. WHY выше).
|
||||
CREATE INDEX IF NOT EXISTS dtp_incidents_geog_idx
|
||||
ON dtp_incidents USING GIST ((geom::geography));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dtp_incidents_dtp_at_idx
|
||||
ON dtp_incidents (dtp_at);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
-- 295_scrape_schedules_seed_dtp_stat_refresh.sql
|
||||
-- Scheduler seed for the ДТП local-mirror refresh job (#3410).
|
||||
--
|
||||
-- WHAT (source='dtp_stat_refresh'):
|
||||
-- trigger_dtp_stat_refresh_run (scheduler.py) → run_dtp_stat_refresh
|
||||
-- (app/tasks/dtp_stat_refresh.py) → app/services/dtp_stat_loader.py: download ZIP
|
||||
-- (dtp-stat.ru), потоковый парс GeoJSON, TRUNCATE dtp_incidents; INSERT — построчно
|
||||
-- строим geom из lon/lat явно (ST_SetSRID(ST_MakePoint(...))).
|
||||
--
|
||||
-- ПОЧЕМУ enabled=false (по образцу house_dedup_merge, seed 135): источник — открытый
|
||||
-- годовой дамп, а не живой поток; на момент проверки 08.09.2026 Last-Modified
|
||||
-- 26.02.2026 — дельта между рефрешами пустая по построению, пустая дельта НЕ баг
|
||||
-- (см. докстринг app/services/dtp_stat_loader.py). Первый прогон — вручную/через
|
||||
-- orchestrator, включение регулярного расписания — отдельное решение продукта.
|
||||
--
|
||||
-- interval_days=30 (default_params, НЕ отдельная колонка — compute_next_run_at читает
|
||||
-- именно default_params.interval_days, ровно как в 135/212): месячный дамп двигается
|
||||
-- редко, ежемесячная проверка — с запасом.
|
||||
--
|
||||
-- Window 11:00-12:00 UTC (14:00-15:00 МСК): после osm_poi_ekb_refresh (10:00-11:00
|
||||
-- UTC, 170), не пересекает другие DB-only/сетевые джобы. Внешний HTTP на dtp-stat.ru,
|
||||
-- но разовый один ZIP-запрос ~5 МБ — не конкурирует за прокси-пул (обычные площадки
|
||||
-- не трогает).
|
||||
--
|
||||
-- next_run_at = завтра 11:00 UTC (NOT NULL — конвенция сидов; безвредно при
|
||||
-- enabled=false, т.к. get_due_schedules сначала фильтрует enabled=true).
|
||||
--
|
||||
-- DEPENDENCIES: 052_scrape_schedules.sql (table + UNIQUE(source)), 294_dtp_incidents.sql.
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING.
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'dtp_stat_refresh',
|
||||
false,
|
||||
11,
|
||||
12,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||
'{"interval_days": 30}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -31,6 +31,8 @@ dependencies = [
|
|||
"bcrypt>=4.2.0", # password hashing для DB-auth (#2550)
|
||||
"playwright>=1.45", # Playwright client для connect к tradein-browser (#905)
|
||||
"prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078)
|
||||
"ijson>=3.3.0", # потоковый парс ~71 МБ GeoJSON dtp-stat.ru (#3410) —
|
||||
# без json.load() целиком в память
|
||||
"scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов;
|
||||
# резолвится из workspace (см. [tool.uv.sources]), не с PyPI.
|
||||
# Docker-context = tradein-mvp root, uv sync ставит editable.
|
||||
|
|
|
|||
88
tradein-mvp/backend/tests/test_dtp_index.py
Normal file
88
tradein-mvp/backend/tests/test_dtp_index.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Тесты радиусных агрегатов ДТП (app/services/dtp_index.py, мигр. 294, #3410).
|
||||
|
||||
Coverage:
|
||||
- _bbox_from_point: геометрически разумный bbox (south<north, west<east, точка внутри).
|
||||
- compute_dtp_stats: graceful fallback при пустой dtp_incidents (status="unavailable",
|
||||
БЕЗ сфабрикованных нулей-как-"ok") — MagicMock db.
|
||||
- compute_dtp_stats: "ok" путь считает агрегаты из mapping-строки (MagicMock db).
|
||||
- Статические asserts по _DTP_STATS_SQL: ST_DWithin, geom::geography, CAST(...),
|
||||
make_interval (не bare :years::interval), нет bare :x:: кастов параметров.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import dtp_index as di
|
||||
|
||||
|
||||
def test_bbox_from_point_contains_center() -> None:
|
||||
lat, lon, radius_m = 56.8389, 60.6057, 1000
|
||||
south, north, west, east = di._bbox_from_point(lat, lon, radius_m)
|
||||
assert south < lat < north
|
||||
assert west < lon < east
|
||||
# Порядок величины: ~1000м ~ 0.009° по широте — не на порядок больше/меньше.
|
||||
assert 0.001 < (north - south) < 0.05
|
||||
assert 0.001 < (east - west) < 0.1
|
||||
|
||||
|
||||
def test_compute_dtp_stats_unavailable_on_empty_table() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.scalar.return_value = 0
|
||||
|
||||
result = di.compute_dtp_stats(db, 56.8389, 60.6057)
|
||||
|
||||
assert result.status == "unavailable"
|
||||
assert result.incidents_count == 0
|
||||
assert result.dead == 0
|
||||
assert result.injured == 0
|
||||
# Только один execute — count(*) проверка; агрегатный запрос не выполняется.
|
||||
assert db.execute.call_count == 1
|
||||
|
||||
|
||||
def test_compute_dtp_stats_ok_path() -> None:
|
||||
db = MagicMock()
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 31253
|
||||
|
||||
stats_row = {
|
||||
"incidents_count": 12,
|
||||
"severe_count": 3,
|
||||
"dead_total": 1,
|
||||
"injured_total": 9,
|
||||
}
|
||||
stats_result = MagicMock()
|
||||
stats_result.mappings.return_value.first.return_value = stats_row
|
||||
|
||||
db.execute.side_effect = [count_result, stats_result]
|
||||
|
||||
result = di.compute_dtp_stats(db, 56.8389, 60.6057, radius_m=800, years=3)
|
||||
|
||||
assert result.status == "ok"
|
||||
assert result.radius_m == 800
|
||||
assert result.years == 3
|
||||
assert result.incidents_count == 12
|
||||
assert result.severe_count == 3
|
||||
assert result.dead == 1
|
||||
assert result.injured == 9
|
||||
|
||||
stats_call = db.execute.call_args_list[1]
|
||||
assert stats_call.args[0] is di._DTP_STATS_SQL
|
||||
params = stats_call.args[1]
|
||||
assert params["radius_m"] == 800
|
||||
assert params["years"] == 3
|
||||
|
||||
|
||||
def test_dtp_stats_sql_static_shape() -> None:
|
||||
sql = re.sub(r"\s+", " ", str(di._DTP_STATS_SQL.text))
|
||||
assert "ST_DWithin(" in sql
|
||||
assert "geom::geography" in sql
|
||||
assert "CAST(:radius_m AS double precision)" in sql
|
||||
assert "CAST(:bbox_south AS double precision)" in sql
|
||||
assert "make_interval(years => CAST(:years AS integer))" in sql
|
||||
# psycopg v3 trap: bind-параметр не должен иметь :name::type сразу за собой.
|
||||
assert not re.search(r":[a-zA-Z_]+::[a-zA-Z]", sql)
|
||||
272
tradein-mvp/backend/tests/test_dtp_stat_loader.py
Normal file
272
tradein-mvp/backend/tests/test_dtp_stat_loader.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""Тесты dtp-stat.ru loader'а (app/services/dtp_stat_loader.py, мигр. 294, #3410).
|
||||
|
||||
Coverage:
|
||||
- Чистые парс-хелперы: _parse_datetime / _parse_str_list / _parse_int.
|
||||
- _parse_feature на РЕАЛЬНОЙ первой записи sverdlovskaia-oblast.geojson (id=74, "ул
|
||||
Победы, 31") — обязательный тест, что vehicles/participants (ПДн физлиц) НЕ
|
||||
попадают в результат парсинга ни в каком виде.
|
||||
- iter_incidents — потоковый парс (ijson) мини-FeatureCollection, пропуск невалидных
|
||||
фич (нет geometry.Point, нет id).
|
||||
- download_dtp_zip / load_dtp_incidents(src_path=...) — HTTP замокан httpx.MockTransport,
|
||||
zip без .geojson entry -> ValueError.
|
||||
- load_dtp_incidents dry_run: TRUNCATE/INSERT не вызываются, счётчик parsed верный.
|
||||
- load_dtp_incidents real-run: TRUNCATE вызван, INSERT чанками, rowcount суммируется
|
||||
(MagicMock db).
|
||||
- Статические asserts по SQL: _INSERT_SQL (ON CONFLICT DO NOTHING, CAST(...), geom
|
||||
строится явно ST_SetSRID/ST_MakePoint, нет bare :x:: кастов).
|
||||
- Гейт: `load_dtp_incidents` не коммитит (коммитит caller — app/tasks/dtp_stat_refresh.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import dtp_stat_loader as dsl
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Фикстура: РЕАЛЬНАЯ первая запись sverdlovskaia-oblast.geojson (проверено 08.09.2026,
|
||||
# id=74, "г Екатеринбург, ул Победы, 31") — включает vehicles/participants (ПДн) НАРОЧНО,
|
||||
# чтобы доказать, что парсер их не трогает.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_FEATURE_74 = json.loads(
|
||||
'{"type": "Feature", "geometry": {"type": "Point", "coordinates": [60.603333, 56.896944]}, '
|
||||
'"properties": {"id": 74, "tags": ["Дорожно-транспортные происшествия"], '
|
||||
'"light": "В темное время суток, освещение включено", '
|
||||
'"point": {"lat": 56.896944, "long": 60.603333}, '
|
||||
'"nearby": ["Отсутствие в непосредственной близости объектов УДС и объектов притяжения", '
|
||||
'"Перегон (нет объектов на месте ДТП)"], "region": "Свердловская область", '
|
||||
'"scheme": "200", "address": "г Екатеринбург, ул Победы, 31", "weather": ["Снегопад"], '
|
||||
'"category": "Столкновение", "datetime": "2015-01-01 03:00:00", "severity": "Тяжёлый", '
|
||||
'"vehicles": [{"year": 2006, "brand": "ВАЗ", "color": "Иные цвета", '
|
||||
'"model": "Нива ВАЗ-2121 и модификации", "category": "D-класс (средний) до 4,6 м", '
|
||||
'"participants": [{"id": 1649862, "role": "Водитель", "gender": "Мужской", '
|
||||
'"violations": ["Выезд на полосу встречного движения"], '
|
||||
'"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
|
||||
'"years_of_driving_experience": 6}]}, {"year": 2013, "brand": "DAEWOO", "color": "Белый", '
|
||||
'"model": "Pathfinder", "category": "D-класс (средний) до 4,6 м", "participants": '
|
||||
'[{"id": 1649863, "role": "Водитель", "gender": "Мужской", "violations": ["Нет нарушений"], '
|
||||
'"health_status": "Не пострадал", "years_of_driving_experience": 15}, '
|
||||
'{"id": 1649864, "role": "Пассажир", "gender": "Женский", "violations": ["Нет нарушений"], '
|
||||
'"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
|
||||
'"years_of_driving_experience": null}, {"id": 1649865, "role": "Пассажир", '
|
||||
'"gender": "Мужской", "violations": ["Нет нарушений"], '
|
||||
'"health_status": "Раненый, находящийся (находившийся) на стационарном лечении", '
|
||||
'"years_of_driving_experience": null}]}], "dead_count": 0, "gibdd_number": "157467063", '
|
||||
'"participants": [], "injured_count": 3, "parent_region": null, '
|
||||
'"road_conditions": ["Не установлены"], "participants_count": 4, '
|
||||
'"participant_categories": ["Все участники"]}}'
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Чистые парс-хелперы
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_datetime_happy_and_garbage() -> None:
|
||||
dt = dsl._parse_datetime("2015-01-01 03:00:00")
|
||||
assert dt is not None
|
||||
assert (dt.year, dt.month, dt.day, dt.hour) == (2015, 1, 1, 3)
|
||||
assert dsl._parse_datetime(None) is None
|
||||
assert dsl._parse_datetime("") is None
|
||||
assert dsl._parse_datetime("не дата") is None
|
||||
|
||||
|
||||
def test_parse_str_list() -> None:
|
||||
assert dsl._parse_str_list(["a", "b"]) == ["a", "b"]
|
||||
assert dsl._parse_str_list([]) is None
|
||||
assert dsl._parse_str_list(None) is None
|
||||
assert dsl._parse_str_list("не список") is None
|
||||
|
||||
|
||||
def test_parse_int() -> None:
|
||||
assert dsl._parse_int(3) == 3
|
||||
assert dsl._parse_int(0) == 0
|
||||
assert dsl._parse_int(None) is None
|
||||
assert dsl._parse_int("3") is None
|
||||
assert dsl._parse_int(True) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# _parse_feature — ПДн-гейт
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_feature_real_record_no_pii() -> None:
|
||||
row = dsl._parse_feature(_FEATURE_74)
|
||||
assert row is not None
|
||||
assert row.source_id == "74"
|
||||
assert row.address == "г Екатеринбург, ул Победы, 31"
|
||||
assert row.category == "Столкновение"
|
||||
assert row.severity == "Тяжёлый"
|
||||
assert row.dead == 0
|
||||
assert row.injured == 3
|
||||
assert row.weather == ["Снегопад"]
|
||||
assert len(row.nearby) == 2
|
||||
assert row.lat == pytest.approx(56.896944)
|
||||
assert row.lon == pytest.approx(60.603333)
|
||||
assert (row.dtp_at.year, row.dtp_at.month, row.dtp_at.day) == (2015, 1, 1)
|
||||
|
||||
# ПДн-гейт: dataclass не несёт ни vehicles, ни participants ни в каком поле.
|
||||
field_names = {f for f in dsl.DtpIncidentRow.__dataclass_fields__}
|
||||
assert "vehicles" not in field_names
|
||||
assert "participants" not in field_names
|
||||
row_values = repr(row)
|
||||
assert "Водитель" not in row_values
|
||||
assert "Мужской" not in row_values
|
||||
assert "1649862" not in row_values
|
||||
|
||||
|
||||
def test_parse_feature_skips_non_point_geometry() -> None:
|
||||
feature = {"geometry": {"type": "Polygon", "coordinates": []}, "properties": {"id": 1}}
|
||||
assert dsl._parse_feature(feature) is None
|
||||
|
||||
|
||||
def test_parse_feature_skips_missing_id() -> None:
|
||||
feature = {
|
||||
"geometry": {"type": "Point", "coordinates": [60.0, 56.0]},
|
||||
"properties": {},
|
||||
}
|
||||
assert dsl._parse_feature(feature) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# iter_incidents — потоковый парс
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _feature_collection_bytes(features: list[dict]) -> bytes:
|
||||
return json.dumps({"type": "FeatureCollection", "features": features}).encode("utf-8")
|
||||
|
||||
|
||||
def test_iter_incidents_streams_and_skips_invalid() -> None:
|
||||
garbage = {
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": []},
|
||||
"properties": {},
|
||||
}
|
||||
data = _feature_collection_bytes([_FEATURE_74, garbage])
|
||||
rows = list(dsl.iter_incidents(io.BytesIO(data)))
|
||||
assert len(rows) == 1
|
||||
assert rows[0].source_id == "74"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# download_dtp_zip / load_dtp_incidents — HTTP замокан
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _zip_bytes(inner_name: str, payload: bytes) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(inner_name, payload)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_load_dtp_incidents_dry_run_downloads_and_counts() -> None:
|
||||
data = _feature_collection_bytes([_FEATURE_74])
|
||||
zip_bytes = _zip_bytes("sverdlovskaia-oblast.geojson", data)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == dsl.SRC_URL
|
||||
return httpx.Response(200, content=zip_bytes)
|
||||
|
||||
client = httpx.Client(transport=httpx.MockTransport(handler))
|
||||
db = MagicMock()
|
||||
|
||||
counts = dsl.load_dtp_incidents(db, client=client, dry_run=True)
|
||||
|
||||
assert counts == {"parsed": 1, "inserted": 0}
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_load_dtp_incidents_real_run_truncates_and_inserts(tmp_path: Path) -> None:
|
||||
data = _feature_collection_bytes([_FEATURE_74])
|
||||
zip_path = tmp_path / "local.zip"
|
||||
zip_path.write_bytes(_zip_bytes("sverdlovskaia-oblast.geojson", data))
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.return_value.rowcount = 1
|
||||
|
||||
counts = dsl.load_dtp_incidents(db, src_path=zip_path, dry_run=False)
|
||||
|
||||
assert counts == {"parsed": 1, "inserted": 1}
|
||||
calls = db.execute.call_args_list
|
||||
assert calls[0].args[0] is dsl._TRUNCATE_SQL
|
||||
assert calls[1].args[0] is dsl._INSERT_SQL
|
||||
inserted_rows = calls[1].args[1]
|
||||
assert len(inserted_rows) == 1
|
||||
assert inserted_rows[0]["source_id"] == "74"
|
||||
assert "vehicles" not in inserted_rows[0]
|
||||
assert "participants" not in inserted_rows[0]
|
||||
|
||||
|
||||
def test_zip_without_geojson_raises() -> None:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("readme.txt", "not geojson")
|
||||
with zipfile.ZipFile(buf) as zf, pytest.raises(ValueError, match="geojson"):
|
||||
dsl._open_geojson_in_zip(zf)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts по SQL + гейт "сервис не коммитит"
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_insert_sql_static_shape() -> None:
|
||||
sql = re.sub(r"\s+", " ", str(dsl._INSERT_SQL.text))
|
||||
assert "ON CONFLICT (source_id) DO NOTHING" in sql
|
||||
assert "CAST(:source_id AS text)" in sql
|
||||
assert "CAST(:weather AS text[])" in sql
|
||||
assert "CAST(:nearby AS text[])" in sql
|
||||
assert "ST_SetSRID(ST_MakePoint(" in sql
|
||||
# psycopg v3 trap: bind-параметр не должен иметь :name::type сразу за собой.
|
||||
assert not re.search(r":[a-zA-Z_]+::[a-zA-Z]", sql)
|
||||
|
||||
|
||||
def test_truncate_sql_static_shape() -> None:
|
||||
assert str(dsl._TRUNCATE_SQL.text).strip() == "TRUNCATE dtp_incidents"
|
||||
|
||||
|
||||
def test_load_dtp_incidents_does_not_commit() -> None:
|
||||
src = inspect.getsource(dsl.load_dtp_incidents)
|
||||
assert "db.commit()" not in src
|
||||
|
||||
|
||||
def _feature(lon: float, lat: float, source_id: int = 1) -> dict:
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"properties": {"id": source_id, "datetime": "2020-05-05 12:00:00"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("lon", "lat"),
|
||||
[
|
||||
(0.0, 0.0), # null island — 10 таких в реальном дампе
|
||||
(1.0, 1.0),
|
||||
(47.0, 41.0),
|
||||
(55.3, 22.183),
|
||||
(59.852, 5.8),
|
||||
],
|
||||
)
|
||||
def test_parse_feature_drops_broken_coordinates(lon: float, lat: float) -> None:
|
||||
"""Битые координаты источника не должны попадать в geo-слой.
|
||||
|
||||
Реальный дамп по Свердловской области (2026-02-26): из 31 253 записей 86 имеют
|
||||
координаты вне региона, в т.ч. 10 ровно в (0,0). Слой кормит радиусные агрегаты
|
||||
локации, поэтому мусор режем на парсе.
|
||||
"""
|
||||
assert dsl._parse_feature(_feature(lon, lat)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("lon", "lat"), [(60.6055, 56.8386), (56.94, 63.283)])
|
||||
def test_parse_feature_keeps_region_points_including_northern_tip(lon: float, lat: float) -> None:
|
||||
"""Рамка шире официальных границ — север области (~63°) не срезается."""
|
||||
row = dsl._parse_feature(_feature(lon, lat))
|
||||
assert row is not None
|
||||
assert row.lat == lat and row.lon == lon
|
||||
58
tradein-mvp/uv.lock
generated
58
tradein-mvp/uv.lock
generated
|
|
@ -607,6 +607,62 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ijson"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/06/b31f040a8764336a11152e474a7abcb3782fedb0d1cdf78f442b82878c56/ijson-3.5.1.tar.gz", hash = "sha256:af40bd1a85f55db0b8b30715c858761306bd92d5590148636f75c3309e6e76bd", size = 69913, upload-time = "2026-07-06T17:37:42.923Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/6e/f3ded1ebb85ccc89a30f7b10a0076f30db70ae1d1e0b6423ff93c57b7539/ijson-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee60c7741012671867678eae71c51872cac938b76f3d4ca40a778e6c361774d2", size = 88643, upload-time = "2026-07-06T17:36:28.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f2/18f14a1d79ef4898e746b4f50dcdbe60abab317cc2bd8390f043b9553c4e/ijson-3.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:11c1d7d36a13054b5872ecd5d745dc4009d9abdbcba2312de69e66c2f92a46d2", size = 60611, upload-time = "2026-07-06T17:36:29.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c7/6e3e591324fd4c7a7a9e1bc23548bacbd84c0d91766b71f09f13e945e7e9/ijson-3.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9517efbe6604bce16f3e50d49b0cd1bdc58917f98cf2eab026599c5c0422991", size = 60447, upload-time = "2026-07-06T17:36:30.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a5/9af7be670381ddac26dd55107ed0110b50f5161673b053311db67f510dcc/ijson-3.5.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea4fd7bec203a600b1cc88a492dfe6b75ce4b1b87488a66adcd5406022213f64", size = 139092, upload-time = "2026-07-06T17:36:31.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/fb/f9c1664d75467453e6bd4e5f9cd2211b730b09e049445ab64cbac68cc6a3/ijson-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350caea815e53151994b597abc80cf669454276b5ac6aadcec69ef6d48f7e90b", size = 149921, upload-time = "2026-07-06T17:36:32.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/80/d20b1c49c4aa7cc6644131e2e57192b45346ef4816566ed1cd9fd05bae38/ijson-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4fcebfe1685bb7ba06a8255a5d428ea6b4b895d7acf979cb637d8bbc9db2f47", size = 149848, upload-time = "2026-07-06T17:36:34.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/fc/5baa710869f5ab939e6233583ced1546889b55c35f35b844c518ac10abc3/ijson-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d78f362f51c8691798758a9e6ac3c9d385ee1228cb82987c91562a2fae235cd3", size = 150810, upload-time = "2026-07-06T17:36:35.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/a12b3d987a5c1677b04557c6f9b9feb7e04b7d4171e9a344856cb9136e9b/ijson-3.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0b184180d45f85fd4479659582749b109e49f4a29c21ac700ccc9c2280fe015e", size = 142989, upload-time = "2026-07-06T17:36:36.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/63/1026c535671fc334fc85aeb78f0945c825e7a338575edc753c0f455459ae/ijson-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e353891d33a2e6aa5caf72c2a5fbadd7a46f5f9b32dcfd0c84113b2444c255b8", size = 151702, upload-time = "2026-07-06T17:36:37.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/af/b58aa3a2bf4d31c388ea78b49826605f60932891ce97e404d196766b4ea3/ijson-3.5.1-cp312-cp312-win32.whl", hash = "sha256:936f28671f018f8ac4d3f003ae9fa01d0467ab4ef4cfd0c97f23beda485b61c6", size = 52613, upload-time = "2026-07-06T17:36:38.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/66/ce70a92949c2a753dad91fdd5761dc14f3a44517e80cfc3c26612982ed61/ijson-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:322c783f3ee0c6b383bbd4db88370b10172168808cc2a0bf811f1253f7435602", size = 54729, upload-time = "2026-07-06T17:36:39.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ff/e17784240c9cf1d58de2f2853ebaf9cc54f6bce117a1f12a6150bbb4a5aa/ijson-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:e2ac204b59f09e38e16d277f906240e9fd38780e42076599419265af183dc4b4", size = 53714, upload-time = "2026-07-06T17:36:40.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c0/5384ccf4fc497ae3dc79a5a28561b05518b503ade29daf3898168d640406/ijson-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3c0556d628443d3e871f414855313b2ae6cd9faa0104de3316bd8db03aab1589", size = 88652, upload-time = "2026-07-06T17:36:41.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/42/58769b8b6d614adb15c2c938c77bcdbfadfba8b1d21a98b5b09cb8961adc/ijson-3.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12aa7fcf46f0fdc8e9e7cf37541e1dc20ac3f9243a23f4d346ab5395f72b0fe2", size = 60607, upload-time = "2026-07-06T17:36:42.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/4a/8322c2824c24184880587bbca45531127a21a4b3bfc897f13427fea02424/ijson-3.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a96066d8c12a18ce2fa90579f2bbf991377cb71725874932e4a5d855226c162a", size = 60447, upload-time = "2026-07-06T17:36:43.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/43/7bdca8f733c45ce97f61a64fadd3e51d255c4c9b467345cbf71ccc7bb368/ijson-3.5.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a19413a092d458a57aaa574fec08e265851d3b5c6e018377f426cd5e70b91280", size = 138889, upload-time = "2026-07-06T17:36:45.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/dc/e8a2e63700ab1d63aaf3fa38c454f8178eaa5b80a6d7c019d1d61b490a6c/ijson-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65974568748678165d7e90e3e7ce2f7c233cfe4de6c37fbb0760941c97e14632", size = 149933, upload-time = "2026-07-06T17:36:46.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/56/640a4d980f7f2c11e399a7fd5ccb9e3d3c9e1dec3a1d5a10024570697c25/ijson-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bad5d55c99c89de8cd0a4cded51f86427ba3353c4dccca37ec2e32e06f26b437", size = 149857, upload-time = "2026-07-06T17:36:47.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/a1/c953e22c83992b69ae538a83b3678d28768f1a48042fc7794733423a5ce7/ijson-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a38d503ce343952e88edfd9a27296a4ec96af7073a9db58b3df6233367f75fc", size = 151141, upload-time = "2026-07-06T17:36:48.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ab/8fe5b7269b140e6e5f8837a33ce980fd9b67c70d0f8114289ed1cea4dace/ijson-3.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2f41982c73896acab4a2a14faa14e152e444bd69f37c3139204429fd3fe65a10", size = 143112, upload-time = "2026-07-06T17:36:50.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f3/23d1284edcde50ba337ddfba5b5d59f8273084d98b28af94715e73dd2b64/ijson-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3321fede2b638d400de0036889a3a25c3bb689feb8df45e70a393346aad6194f", size = 152184, upload-time = "2026-07-06T17:36:51.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/4e/df61be89dd295e4da722ec96ba03b1765bcb2becdaaaede9c96a7d2365b6/ijson-3.5.1-cp313-cp313-win32.whl", hash = "sha256:af6ddbd10ac9bce87a835f2de3ec61455ec435c54e7e0ba7b17c31c66de6f164", size = 52607, upload-time = "2026-07-06T17:36:52.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d9/03e5dbd3ef7e0cee06fbef0f87b91d7ce1c07fae9b5a1b0ca8b895de62c4/ijson-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:1de3de278b0ffb40338374ad2a730e1c56f933e0706b1815ebeb07b82239b1a3", size = 54730, upload-time = "2026-07-06T17:36:53.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/30/4f37076c88a96a1a5e44df38b59fade4f59eaef87ef8b5162d55b2d426d5/ijson-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:c8a36a19b92cb7172c6448ab94f446033cfa3129dc4894aebe205f96b3fabf42", size = 53719, upload-time = "2026-07-06T17:36:54.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/17/54f9180c0da9a9e96e5b3791bc74093f029a2344678b4da218c2699465bf/ijson-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21e1a250b254edba2f0dd7272a4c56f0a879aabe328d9e306dd1fc115f560e74", size = 89223, upload-time = "2026-07-06T17:36:55.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/70/0ee0d2627c534174455a745ca25284797e71b0d6e2b2a1b31cc914e7b462/ijson-3.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e01f95433725e2df62d682ff88e4a57bb694385ff2362bc364adec961167ae04", size = 60831, upload-time = "2026-07-06T17:36:56.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e6/56f64ba7a3e7a25d9a9fbbeb4c30597d6b76c1094cc2041d11a3224b562c/ijson-3.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:539e8d6cca079bcbb68c390e55148f908e0a943a34f7dd321248637c6272adca", size = 60752, upload-time = "2026-07-06T17:36:57.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/2b/5a55db881f1b043cd6d5716578937a60ac16348be1a3afbf846b21cf4b44/ijson-3.5.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:32f64051be2f990d8ae7b614b5abdf4a7bead510ce3666568d7403c6c46ce4d8", size = 140783, upload-time = "2026-07-06T17:36:58.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/61/f7783cc18672dc31544141139efd187fb34795d24e573fed6abea6b776c7/ijson-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd0dfc5a788d0b0c2f1eab258b9dabdeefc631ca8ef87644a999f633b0b2555a", size = 149976, upload-time = "2026-07-06T17:37:00.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d6/4182dd63b6b70eae4f5208c53558a050895a40734dff283463033c153742/ijson-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42bfda7858d99ee9777ec28cb6d347928249eefeb577f9b0a67503c18f7ebb6a", size = 149317, upload-time = "2026-07-06T17:37:01.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/b1/a675e4a9b428a0ef556e7d718bf0e6885e3e5543042248a1a7030899a3d4/ijson-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c4b9a28e9719d1aebebe93ad8dc2ba87f4e2d9035043b196c1c07ef8530b44cc", size = 150555, upload-time = "2026-07-06T17:37:02.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/69/52686f56b44af63a93c3dc3f5bcfa07f87427d9aea4d2cbe3e1c94188c74/ijson-3.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9a0b25c750a6bde14a0b31f1dcbfc86368e50767e3eaa73bb138e54128055edd", size = 144485, upload-time = "2026-07-06T17:37:03.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/46/10554e817dde56300a8414e52c0f5a44a29f3440327cd6d829ece57759b3/ijson-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bd756f7b22df745ac14b7bc2ab9ed7c190a222e4c8e1bef26ef1162af8e54d0f", size = 151470, upload-time = "2026-07-06T17:37:04.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/82/f37cbb110b48abdb623d169d0e196f2f6e064e2c20fa789ecde6e69b0440/ijson-3.5.1-cp314-cp314-win32.whl", hash = "sha256:e035cdfb2a1446b13881f0dfc0eecd1541cbb17a27a938ded2160ae6ce25051b", size = 53219, upload-time = "2026-07-06T17:37:06.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/58/792df8f001c246c8ff28f860de81d35ea0d797c0d3276c22a2af83089656/ijson-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:eeb2fb2daa5dd30326f93db465d0855b34aa6b1f52a7c0ff94522aec5ad57dfb", size = 55485, upload-time = "2026-07-06T17:37:07.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3c/db3ccc22c09ed4738787e8d82fff76101aa81ec8de7eaf6572e065e012d3/ijson-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:a96ab35d7ce2129dfde49c4c807596443410e260d7f7a4ca8fe4d0035553b589", size = 54390, upload-time = "2026-07-06T17:37:08.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/59/eefa5d9488250c03f24152576804205ae40e29cac0dc65cbbc5f3d422008/ijson-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:77b68e91f95fb16ac2e7819903cd545db6cffa308c28833cc34911e6b21e91dd", size = 93177, upload-time = "2026-07-06T17:37:09.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/db/6329eb7bb9f1906c1906fc10e7074b8f08bf39b7d50baa58f1b597d48898/ijson-3.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:94a95065b1ac67602af0cec852b07505abc37b77e3774d1c801d935d05e48f82", size = 62891, upload-time = "2026-07-06T17:37:10.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d0/b3beddb96eef0b20bb9902c36e4de30f145be06d7e5e1d780e1a1689d0ce/ijson-3.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b70b5da6b0571da8f601a437c4fba2d35bc27739637d85f3acdc8f88916ce68e", size = 62575, upload-time = "2026-07-06T17:37:11.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/01/95f3a7c27d25bb917954ef0c8e86d0e60f585b9db675cbd05d355f54cce8/ijson-3.5.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ade373dd765b057b1dec05d7711bfeb5a36f1e825259466d9f545cfd8ef3ba3", size = 200568, upload-time = "2026-07-06T17:37:12.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/61/c94ee4ea1f22318aab9a49b35d0ce8ac87dd24d508ea4c77dcbde362ba5e/ijson-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:882bc0bdd25d41eae90a15695cd50707edde0978b8b72a2532e30442dd8fd04c", size = 217956, upload-time = "2026-07-06T17:37:14.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/82/43e8d225aea5ee00eef7998c8ce41f344f7ba451329dfa9e92f4700813af/ijson-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451901c36e12fa87cbb1cafe661bd25c08c6bd7900cc738279614f71cea07048", size = 208403, upload-time = "2026-07-06T17:37:15.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/6f/375f67fad76677aca9bc0817b2b18fdd231d309fe24e26b19a5556ef6cdd/ijson-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c5f660658f2ebfba5d4dfe4bafe8cd3a0defcda410ec08d2205fe08c398940", size = 211967, upload-time = "2026-07-06T17:37:16.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/53/4c754c3ba18ec70b7086b91a4abd368358fc47cc9b3871afd50deef4fea1/ijson-3.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:29eb8f0c77a296a10843a1714ad4a5d561e604cda3c88585e9012cf2c1729b0a", size = 201020, upload-time = "2026-07-06T17:37:18.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/2d/3e7191b3222a31c378b827565b4fa64676a293441279f84db3d971720bf5/ijson-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85997568d6b304cfa59d5c3f2b04f95b92e9a8c7f57d312343a7989cf8dfff85", size = 205584, upload-time = "2026-07-06T17:37:19.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/11/55ae9c915e68f37c8698f8b09355071dc808ced5e9d4abf8238dc363f500/ijson-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:c2e2509dc7f2fa5a2ac9ba7d15dd901f4093bd36b0784f65e04b681b7956651c", size = 54438, upload-time = "2026-07-06T17:37:20.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/df/5bf2656447f14a923d25a0401b1cd628ca05c23041d3a4c116ae8d44dc39/ijson-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2699e838099d056818c5f8e4ba702b345d0304e58847bdc79c5c1616d5d750a5", size = 56467, upload-time = "2026-07-06T17:37:21.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/e4/dec06e84fac704039625039c6b116a44f17ad72fda48b8f88a2493364b77/ijson-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:c388f85cbb9eec022b2bdedd23ffacfe7ab100c1200b1f47bee6e6ea2c3309fa", size = 55774, upload-time = "2026-07-06T17:37:22.958Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
|
|
@ -1689,6 +1745,7 @@ dependencies = [
|
|||
{ name = "fastapi" },
|
||||
{ name = "geoalchemy2" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "ijson" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "lxml" },
|
||||
{ name = "matplotlib" },
|
||||
|
|
@ -1726,6 +1783,7 @@ requires-dist = [
|
|||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "geoalchemy2", specifier = ">=0.15.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.27.0" },
|
||||
{ name = "ijson", specifier = ">=3.3.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||
{ name = "lxml", specifier = ">=5.0.0" },
|
||||
{ name = "matplotlib", specifier = ">=3.9.0" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue