Files
airflow-greenplum/scripts/check_airports_snapshot.py

159 lines
7.4 KiB
Python

"""Независимая сверка лабораторной PXF: Postgres → сохраненный файл → STG/ODS."""
from __future__ import annotations
import argparse
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FIELDS = ("airport_code", "airport_name", "city", "country", "coordinates", "timezone")
JSON_FIELDS = {"airport_name", "city", "country"}
def read_rows(service: str, query: str) -> list[dict]:
"""Читает через psql в контейнере; настройки берет из окружения Compose."""
if service == "bookings-db":
command = (
'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -X -qAt '
'-v ON_ERROR_STOP=1 -h 127.0.0.1 -U "$POSTGRES_USER" -d demo'
)
else:
command = (
'PGPASSWORD="$GREENPLUM_PASSWORD" exec '
"/usr/local/greenplum-db/bin/psql -X -qAt -v ON_ERROR_STOP=1 "
'-h 127.0.0.1 -U "$GREENPLUM_USER" -d "$GREENPLUM_DATABASE_NAME"'
)
result = subprocess.run(
["docker", "compose", "exec", "-T", service, "sh", "-c", command],
input=f"SELECT row_to_json(r) FROM ({query}) r;\n",
text=True,
capture_output=True,
cwd=ROOT,
check=True,
)
return [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
def compare_snapshot(
expected: list[dict], actual: list[dict], batch_id: str, layer: str
) -> list[str]:
"""Проверяет ключи, все бизнес-поля и метки; порядок строк не важен."""
errors = []
if not expected:
return ["Контрольный источник пуст. Сначала восстановите Bookings."]
source = {row["airport_code"]: row for row in expected}
if len(source) != len(expected) or None in source or "" in source:
return ["В контрольном источнике пустые или повторяющиеся ключи."]
keys = [row["airport_code"] for row in actual]
if len(keys) != len(set(keys)):
errors.append("Повторяются airport_code внутри снимка.")
missing = source.keys() - set(keys)
extra = set(keys) - source.keys()
if missing:
errors.append(
f"Не получены ключи: {sorted(missing)[:5]} (всего {len(missing)})."
)
if extra:
errors.append(f"Лишние ключи: {sorted(map(str, extra))[:5]}.")
for row in actual:
key = row["airport_code"]
if row.get("_load_id") != batch_id:
errors.append(
f"{key}: _load_id не равен {batch_id}; проверьте выбор батча."
)
timestamps = ("_load_ts", "event_ts") if layer == "stg" else ("_load_ts",)
if any(not row.get(field) for field in timestamps):
errors.append(f"{key}: не заполнены метки времени {timestamps}.")
if key not in source:
continue
for field in FIELDS:
wanted, received = source[key][field], row[field]
if field in JSON_FIELDS:
# Сравниваем весь JSON в STG, включая языки, кроме русского.
wanted = json.loads(wanted) if wanted is not None else None
if layer == "ods":
wanted = wanted.get("ru") if wanted is not None else None
elif received is not None:
try:
received = json.loads(received)
except (ValueError, TypeError):
errors.append(f"{key}: {field} не содержит исходный JSON.")
continue
if received != wanted:
errors.append(f"{key}: отличается {field}.")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
actions = parser.add_subparsers(dest="action", required=True)
export = actions.add_parser(
"export", help="Сохранить источник напрямую из Postgres"
)
export.add_argument(
"file", type=Path, help="Новый JSON-файл для контрольного источника"
)
check = actions.add_parser("check", help="Сверить снимок с сохраненным источником")
check.add_argument("batch_id", help="Run ID нужного запуска STG")
check.add_argument("file", type=Path, help="Контрольный JSON-файл из export")
check.add_argument("--layer", choices=("stg", "ods"), default="stg")
args = parser.parse_args()
try:
if args.action == "export":
fields = ", ".join(f"{field}::text AS {field}" for field in FIELDS)
rows = read_rows(
"bookings-db",
f"SELECT {fields} FROM bookings.airports_data ORDER BY airport_code",
)
if not rows:
raise ValueError(
"Источник пуст. Пройдите инициализацию Bookings из README."
)
rows.sort(key=lambda row: row["airport_code"])
# Не даем случайно заменить контроль A данными измененного источника B.
with args.file.open("x", encoding="utf-8") as output:
json.dump(rows, output, ensure_ascii=False, indent=2)
output.write("\n")
print(f"Источник: {len(rows)} аэропортов сохранено в {args.file}.")
return 0
expected = json.loads(args.file.read_text(encoding="utf-8"))
# Run ID передается SQL-литералом, а не частью команды shell.
batch_literal = "'" + args.batch_id.replace("'", "''") + "'"
query = f"SELECT * FROM {args.layer}.airports"
if args.layer == "stg":
query += f" WHERE _load_id = {batch_literal}"
actual = read_rows("greenplum", query)
errors = compare_snapshot(expected, actual, args.batch_id, args.layer)
if errors:
print("Сверка не прошла. Проверьте SQL загрузки и выбранный Run ID:")
for error in errors[:10]:
print(f"- {error}")
return 1
print(
f"Сверка пройдена: {args.layer}.airports, _load_id={args.batch_id}, "
f"{len(actual)} ключей; все бизнес-поля и метки совпадают с требованиями."
)
return 0
except subprocess.CalledProcessError as error:
print("Не удалось прочитать БД. Проверьте docker compose ps и настройки .env.")
print(error.stderr.strip())
return 1
except FileExistsError:
print(
"Контрольный файл уже существует. Сохраните его; для нового опыта выберите другой путь."
)
return 1
except (OSError, ValueError, KeyError, TypeError) as error:
print(
f"Не удалось выполнить сверку: {error}. Проверьте путь и формат контрольного файла."
)
return 1
if __name__ == "__main__":
raise SystemExit(main())